Initial work
This commit is contained in:
parent
5e328f8a1c
commit
4428c80103
|
@ -0,0 +1,10 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<module type="PYTHON_MODULE" version="4">
|
||||||
|
<component name="NewModuleRootManager">
|
||||||
|
<content url="file://$MODULE_DIR$">
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/venv" />
|
||||||
|
</content>
|
||||||
|
<orderEntry type="inheritedJdk" />
|
||||||
|
<orderEntry type="sourceFolder" forTests="false" />
|
||||||
|
</component>
|
||||||
|
</module>
|
|
@ -0,0 +1,23 @@
|
||||||
|
"""
|
||||||
|
aiosched: I'm bored and I'm making an async event loop again
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
https: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.
|
||||||
|
"""
|
||||||
|
from aiosched.runtime import run, get_event_loop, new_event_loop, clock
|
||||||
|
from aiosched.internals.syscalls import spawn, wait, sleep, cancel
|
||||||
|
import aiosched.task
|
||||||
|
import aiosched.errors
|
||||||
|
|
||||||
|
__all__ = ["run", "get_event_loop", "new_event_loop", "spawn", "wait", "sleep", "task", "errors", "cancel"]
|
|
@ -0,0 +1,62 @@
|
||||||
|
"""
|
||||||
|
aiosched: I'm bored and I'm making an async event loop again
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
https: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.
|
||||||
|
"""
|
||||||
|
from aiosched.task import Task
|
||||||
|
|
||||||
|
|
||||||
|
class SchedulerError(Exception):
|
||||||
|
"""
|
||||||
|
A generic scheduler error
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class InternalError(SchedulerError):
|
||||||
|
"""
|
||||||
|
Internal exception
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class ResourceBusy(SchedulerError):
|
||||||
|
"""
|
||||||
|
Exception that is raised when a resource is
|
||||||
|
accessed by more than one task at a time
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class ResourceClosed(SchedulerError):
|
||||||
|
"""
|
||||||
|
Raised when I/O is attempted on a closed
|
||||||
|
resource
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class TimedOutError(SchedulerError):
|
||||||
|
"""
|
||||||
|
This is raised if a timeout expires
|
||||||
|
"""
|
||||||
|
|
||||||
|
task: Task
|
||||||
|
|
||||||
|
|
||||||
|
class Cancelled(BaseException):
|
||||||
|
"""
|
||||||
|
A cancellation exception.
|
||||||
|
Inherits from BaseException as
|
||||||
|
it is not meant to be caught
|
||||||
|
"""
|
||||||
|
|
||||||
|
task: Task
|
|
@ -0,0 +1,17 @@
|
||||||
|
"""
|
||||||
|
aiosched: I'm bored and I'm making an async event loop again
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
https: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.
|
||||||
|
"""
|
|
@ -0,0 +1,173 @@
|
||||||
|
"""
|
||||||
|
aiosched: I'm bored and I'm making an async event loop again
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
https: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.
|
||||||
|
"""
|
||||||
|
from typing import Callable, Any
|
||||||
|
from aiosched.task import Task, TaskState
|
||||||
|
from heapq import heappush, heappop, heapify
|
||||||
|
|
||||||
|
|
||||||
|
class TimeQueue:
|
||||||
|
"""
|
||||||
|
An abstraction layer over a heap queue based on time. This is where
|
||||||
|
paused tasks will be put when they are not running
|
||||||
|
|
||||||
|
:param clock: The same monotonic clock that was passed to the thread-local event loop.
|
||||||
|
It is important for the queue to be synchronized with the loop as this allows
|
||||||
|
the sleeping mechanism to work reliably
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, clock: Callable[[], float]):
|
||||||
|
"""
|
||||||
|
Object constructor
|
||||||
|
"""
|
||||||
|
|
||||||
|
self.clock = clock
|
||||||
|
# The sequence float handles the race condition
|
||||||
|
# of two tasks with identical deadlines, acting
|
||||||
|
# as a tiebreaker
|
||||||
|
self.sequence = 0
|
||||||
|
self.container: list[tuple[float, int, Task, dict[str, Any]]] = []
|
||||||
|
|
||||||
|
def __len__(self):
|
||||||
|
"""
|
||||||
|
Returns len(self)
|
||||||
|
"""
|
||||||
|
|
||||||
|
return len(self.container)
|
||||||
|
|
||||||
|
def __contains__(self, item: Task):
|
||||||
|
"""
|
||||||
|
Implements item in self. This method behaves
|
||||||
|
as if the queue only contained tasks and ignores
|
||||||
|
their timeouts and tiebreakers
|
||||||
|
"""
|
||||||
|
|
||||||
|
for i in self.container:
|
||||||
|
if i[2] == item:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def index(self, item: Task):
|
||||||
|
"""
|
||||||
|
Returns the index of the given item in the list
|
||||||
|
or -1 if it is not present
|
||||||
|
"""
|
||||||
|
|
||||||
|
for i, e in enumerate(self.container):
|
||||||
|
if e[2] == item:
|
||||||
|
return i
|
||||||
|
return -1
|
||||||
|
|
||||||
|
def discard(self, item: Task):
|
||||||
|
"""
|
||||||
|
Discards an item from the queue and
|
||||||
|
calls heapify(self.container) to keep
|
||||||
|
the heap invariant if an element is removed.
|
||||||
|
This method does nothing if the item is not
|
||||||
|
in the queue, but note that in this case the
|
||||||
|
operation would still take O(n) iterations
|
||||||
|
to complete
|
||||||
|
|
||||||
|
:param item: The item to be discarded
|
||||||
|
"""
|
||||||
|
|
||||||
|
idx = self.index(item)
|
||||||
|
if idx != -1:
|
||||||
|
self.container.pop(idx)
|
||||||
|
heapify(self.container)
|
||||||
|
|
||||||
|
def get_closest_deadline(self) -> float:
|
||||||
|
"""
|
||||||
|
Returns the closest deadline that is meant to expire
|
||||||
|
or raises IndexError if the queue is empty
|
||||||
|
"""
|
||||||
|
|
||||||
|
if not self:
|
||||||
|
raise IndexError("TimeQueue is empty")
|
||||||
|
return self.container[0][0]
|
||||||
|
|
||||||
|
def __iter__(self):
|
||||||
|
"""
|
||||||
|
Implements iter(self)
|
||||||
|
"""
|
||||||
|
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __next__(self):
|
||||||
|
"""
|
||||||
|
Implements next(self)
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
return self.get()
|
||||||
|
except IndexError:
|
||||||
|
raise StopIteration from None
|
||||||
|
|
||||||
|
def __getitem__(self, item: int):
|
||||||
|
"""
|
||||||
|
Implements self[n]
|
||||||
|
"""
|
||||||
|
|
||||||
|
return self.container.__getitem__(item)
|
||||||
|
|
||||||
|
def __bool__(self):
|
||||||
|
"""
|
||||||
|
Implements bool(self)
|
||||||
|
"""
|
||||||
|
|
||||||
|
return bool(self.container)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
"""
|
||||||
|
Implements repr(self) and str(self)
|
||||||
|
"""
|
||||||
|
|
||||||
|
return f"TimeQueue({self.container}, clock={self.clock})"
|
||||||
|
|
||||||
|
def put(self, task: Task, delay: float, metadata: dict[str, Any] | None = None):
|
||||||
|
"""
|
||||||
|
Pushes a task onto the queue together with its
|
||||||
|
delay and optional metadata
|
||||||
|
|
||||||
|
:param task: The task that is meant to sleep
|
||||||
|
:type task: :class: Task
|
||||||
|
:param delay: The delay associated with the task
|
||||||
|
:type delay: float
|
||||||
|
:param metadata: A dictionary representing additional
|
||||||
|
task metadata. Defaults to None
|
||||||
|
:type metadata: dict[str, Any], optional
|
||||||
|
"""
|
||||||
|
|
||||||
|
time = self.clock()
|
||||||
|
task.paused_when = time
|
||||||
|
task.state = TaskState.PAUSED
|
||||||
|
task.next_deadline = task.paused_when + delay
|
||||||
|
heappush(self.container, (time + delay, self.sequence, task, metadata))
|
||||||
|
self.sequence += 1
|
||||||
|
|
||||||
|
def get(self) -> tuple[Task, dict[str, Any] | None]:
|
||||||
|
"""
|
||||||
|
Gets the first task that is meant to run along
|
||||||
|
with its metadata
|
||||||
|
|
||||||
|
:raises: IndexError if the queue is empty
|
||||||
|
"""
|
||||||
|
|
||||||
|
if not self.container:
|
||||||
|
raise IndexError("get from empty TimeQueue")
|
||||||
|
_, __, task, meta = heappop(self.container)
|
||||||
|
return task, meta
|
|
@ -0,0 +1,172 @@
|
||||||
|
"""
|
||||||
|
aiosched: I'm bored and I'm making an async event loop again
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
https: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 inspect
|
||||||
|
from types import coroutine
|
||||||
|
from typing import Any, Callable, Coroutine
|
||||||
|
from aiosched.task import Task, TaskState
|
||||||
|
from aiosched.errors import SchedulerError
|
||||||
|
from selectors import EVENT_READ, EVENT_WRITE
|
||||||
|
|
||||||
|
|
||||||
|
@coroutine
|
||||||
|
def syscall(method: str, *args, **kwargs) -> Any | None:
|
||||||
|
"""
|
||||||
|
Lowest-level primitive to interact with the event loop:
|
||||||
|
calls a loop method with the provided arguments. This
|
||||||
|
function should not be used directly, but through abstraction
|
||||||
|
layers. All positional and keyword arguments are passed to
|
||||||
|
the method itself and its return value is provided once the
|
||||||
|
loop yields control back to us
|
||||||
|
|
||||||
|
:param method: The loop method to call
|
||||||
|
:type method: str
|
||||||
|
:returns: The result of the method call, if any
|
||||||
|
"""
|
||||||
|
|
||||||
|
result = yield method, args, kwargs
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
async def spawn(func: Callable[..., Coroutine[Any, Any, Any]], *args, **kwargs) -> Task:
|
||||||
|
"""
|
||||||
|
Spawns a task from a coroutine and returns it. The coroutine
|
||||||
|
is put on the running cute and is executed as soon as possible.
|
||||||
|
Any positional and keyword arguments are passed along to the coroutine
|
||||||
|
|
||||||
|
:param func: The coroutine function to instantiate. Note that this should NOT
|
||||||
|
be a coroutine object: the arguments to the coroutine should be passed to
|
||||||
|
spawn as well
|
||||||
|
:return:
|
||||||
|
"""
|
||||||
|
|
||||||
|
if inspect.iscoroutine(func):
|
||||||
|
raise TypeError(
|
||||||
|
"Looks like you tried to call spawn(your_func(arg1, arg2, ...)), that is wrong!"
|
||||||
|
"\nWhat you wanna do, instead, is this: spawn(your_func, arg1, arg2, ...)"
|
||||||
|
)
|
||||||
|
elif inspect.iscoroutinefunction(func):
|
||||||
|
return await syscall("spawn", func, *args, **kwargs)
|
||||||
|
else:
|
||||||
|
raise TypeError("func must be a coroutine function")
|
||||||
|
|
||||||
|
|
||||||
|
async def sleep(delay: int | float):
|
||||||
|
"""
|
||||||
|
Puts the calling task to sleep for the
|
||||||
|
given amount of time. If the delay is equal
|
||||||
|
to zero, this call acts as a checkpoint to
|
||||||
|
perform task switching and is useful to release
|
||||||
|
pressure from the scheduler in highly concurrent
|
||||||
|
environments
|
||||||
|
|
||||||
|
:param delay: The amount of time (in seconds) that
|
||||||
|
the task has to be put to sleep for. Must be
|
||||||
|
greater than zero
|
||||||
|
:type delay: int | float
|
||||||
|
"""
|
||||||
|
|
||||||
|
await syscall("sleep", delay)
|
||||||
|
|
||||||
|
|
||||||
|
async def checkpoint():
|
||||||
|
"""
|
||||||
|
Shorthand for sleep(0)
|
||||||
|
"""
|
||||||
|
|
||||||
|
await sleep(0)
|
||||||
|
|
||||||
|
|
||||||
|
async def suspend():
|
||||||
|
"""
|
||||||
|
Suspends the current task. The task is not
|
||||||
|
rescheduled until some other event (for example
|
||||||
|
a timer, an event or an I/O operation) reschedules
|
||||||
|
it
|
||||||
|
"""
|
||||||
|
|
||||||
|
await syscall("suspend")
|
||||||
|
|
||||||
|
|
||||||
|
async def wait(task: Task) -> Any | None:
|
||||||
|
"""
|
||||||
|
Waits for the completion of a
|
||||||
|
given task and returns its
|
||||||
|
return value. Can be called
|
||||||
|
multiple times by multiple tasks.
|
||||||
|
Raises an error if the task has
|
||||||
|
completed already. Please note that
|
||||||
|
exceptions are propagated, too
|
||||||
|
|
||||||
|
:param task: The task to wait for
|
||||||
|
:type task: :class: Task
|
||||||
|
:returns: The task's return value, if any
|
||||||
|
"""
|
||||||
|
|
||||||
|
if task.done():
|
||||||
|
raise SchedulerError(f"task {task.name!r} has completed already")
|
||||||
|
await syscall("wait", task)
|
||||||
|
if task.exc:
|
||||||
|
raise task.exc
|
||||||
|
return task.result
|
||||||
|
|
||||||
|
|
||||||
|
async def cancel(task: Task):
|
||||||
|
"""
|
||||||
|
Cancels the given task. Note that
|
||||||
|
cancellations may not happen immediately
|
||||||
|
if the task is blocked in an uninterruptible
|
||||||
|
state
|
||||||
|
|
||||||
|
:param task: The task to wait for
|
||||||
|
:type task: :class: Task
|
||||||
|
"""
|
||||||
|
|
||||||
|
await syscall("cancel", task)
|
||||||
|
if task.state != TaskState.CANCELLED:
|
||||||
|
raise SchedulerError(f"task {task.name!r} ignored cancellation")
|
||||||
|
|
||||||
|
|
||||||
|
async def closing(stream):
|
||||||
|
"""
|
||||||
|
Notifies the event loop that the
|
||||||
|
given stream is about to be closed,
|
||||||
|
causing all callers waiting on it
|
||||||
|
to error out with an exception instead
|
||||||
|
of blocking forever
|
||||||
|
"""
|
||||||
|
|
||||||
|
await syscall("notify_closing", stream)
|
||||||
|
|
||||||
|
|
||||||
|
async def wait_readable(stream):
|
||||||
|
"""
|
||||||
|
Waits until the given stream is
|
||||||
|
readable
|
||||||
|
"""
|
||||||
|
|
||||||
|
await syscall("perform_io", stream, EVENT_READ)
|
||||||
|
|
||||||
|
|
||||||
|
async def wait_writable(stream):
|
||||||
|
"""
|
||||||
|
Waits until the given stream is
|
||||||
|
writable
|
||||||
|
"""
|
||||||
|
|
||||||
|
await syscall("perform_io", stream, EVENT_WRITE)
|
|
@ -0,0 +1,492 @@
|
||||||
|
"""
|
||||||
|
aiosched: I'm bored and I'm making an async event loop again
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
https: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 itertools
|
||||||
|
from collections import deque
|
||||||
|
from functools import partial
|
||||||
|
from aiosched.task import Task, TaskState
|
||||||
|
from timeit import default_timer
|
||||||
|
from aiosched.internals.queues import TimeQueue
|
||||||
|
from aiosched.util.debugging import BaseDebugger
|
||||||
|
from typing import Callable, Any, Coroutine
|
||||||
|
from aiosched.errors import InternalError, ResourceBusy, Cancelled, ResourceClosed
|
||||||
|
from selectors import DefaultSelector, BaseSelector
|
||||||
|
|
||||||
|
|
||||||
|
class FIFOKernel:
|
||||||
|
"""
|
||||||
|
An asynchronous event loop implementation with a FIFO
|
||||||
|
scheduling policy.
|
||||||
|
|
||||||
|
:param clock: The function used to keep track of time. Defaults to timeit.default_timer
|
||||||
|
:param debugger: A subclass of aiosched.util.BaseDebugger or None if no debugging output is desired
|
||||||
|
:type debugger: :class: aiosched.util.debugging.BaseDebugger, optional
|
||||||
|
:param selector: The selector to use for I/O multiplexing, defaults to selectors.DefaultSelector
|
||||||
|
:type selector: :class: selectors.DefaultSelector
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
clock: Callable[[], float] = default_timer,
|
||||||
|
debugger: BaseDebugger | None = None,
|
||||||
|
selector: BaseSelector = DefaultSelector(),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Public constructor
|
||||||
|
"""
|
||||||
|
|
||||||
|
self.clock = clock
|
||||||
|
if debugger and not issubclass(type(debugger), BaseDebugger):
|
||||||
|
raise InternalError(
|
||||||
|
"The debugger must be a subclass of aiosched.util.debugging.BaseDebugger"
|
||||||
|
)
|
||||||
|
# The debugger object. If it is none we create a dummy object that immediately returns an empty
|
||||||
|
# lambda which in turn returns None every time we access any of its attributes to avoid lots of
|
||||||
|
# if self.debugger clauses
|
||||||
|
self.debugger = (
|
||||||
|
debugger
|
||||||
|
or type(
|
||||||
|
"DumbDebugger",
|
||||||
|
(object,),
|
||||||
|
{"__getattr__": lambda *_: lambda *_: None},
|
||||||
|
)()
|
||||||
|
)
|
||||||
|
# Abstraction layer over low-level OS
|
||||||
|
# primitives for asynchronous I/O
|
||||||
|
self.selector: BaseSelector = selector
|
||||||
|
# Tasks that are ready to run
|
||||||
|
self.run_ready: deque[Task] = deque()
|
||||||
|
# Tasks that are paused and waiting
|
||||||
|
# for some deadline to expire
|
||||||
|
self.paused: TimeQueue = TimeQueue(self.clock)
|
||||||
|
# Data that is to be sent back to coroutines
|
||||||
|
self.data: dict[Task, Any] = {}
|
||||||
|
# The currently running task
|
||||||
|
self.current_task: Task | None = None
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
"""
|
||||||
|
Returns repr(self)
|
||||||
|
"""
|
||||||
|
|
||||||
|
fields = {
|
||||||
|
"debugger",
|
||||||
|
"run_ready",
|
||||||
|
"selector",
|
||||||
|
"clock",
|
||||||
|
"data",
|
||||||
|
"paused",
|
||||||
|
"current_task",
|
||||||
|
}
|
||||||
|
data = ", ".join(
|
||||||
|
name + "=" + str(value)
|
||||||
|
for name, value in zip(fields, (getattr(self, field) for field in fields))
|
||||||
|
)
|
||||||
|
return f"{type(self).__name__}({data})"
|
||||||
|
|
||||||
|
def done(self) -> bool:
|
||||||
|
"""
|
||||||
|
Returns whether the loop has no more work
|
||||||
|
to do
|
||||||
|
"""
|
||||||
|
|
||||||
|
return not any([self.paused, self.run_ready, self.selector.get_map()])
|
||||||
|
|
||||||
|
def close(self, force: bool = False):
|
||||||
|
"""
|
||||||
|
Closes the event loop. If force equals False,
|
||||||
|
which is the default, raises an InternalError
|
||||||
|
exception. If force equals True, cancels all
|
||||||
|
tasks.
|
||||||
|
|
||||||
|
:param force:
|
||||||
|
:return:
|
||||||
|
"""
|
||||||
|
|
||||||
|
if not self.done() and not force:
|
||||||
|
raise InternalError("cannot shut down a running event loop")
|
||||||
|
for task in self.all():
|
||||||
|
self.cancel(task)
|
||||||
|
|
||||||
|
def all(self) -> Task:
|
||||||
|
"""
|
||||||
|
Yields all the tasks the event loop is keeping track of
|
||||||
|
"""
|
||||||
|
|
||||||
|
for task in itertools.chain(self.run_ready, self.paused):
|
||||||
|
task: Task
|
||||||
|
yield task
|
||||||
|
|
||||||
|
def shutdown(self):
|
||||||
|
"""
|
||||||
|
Shuts down the event loop
|
||||||
|
"""
|
||||||
|
|
||||||
|
for task in self.all():
|
||||||
|
self.io_release_task(task)
|
||||||
|
self.selector.close()
|
||||||
|
self.close()
|
||||||
|
|
||||||
|
def wait_io(self):
|
||||||
|
"""
|
||||||
|
Waits for I/O and implements part of the sleeping mechanism
|
||||||
|
for the event loop
|
||||||
|
"""
|
||||||
|
|
||||||
|
before_time = self.clock() # Used for the debugger
|
||||||
|
timeout = 0.0
|
||||||
|
if self.run_ready:
|
||||||
|
# If there is work to do immediately (tasks to run) we
|
||||||
|
# can't wait
|
||||||
|
timeout = 0.0
|
||||||
|
elif self.paused:
|
||||||
|
# If there are asleep tasks or deadlines, wait until the closest date
|
||||||
|
timeout = self.paused.get_closest_deadline()
|
||||||
|
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:
|
||||||
|
self.run_ready.extend(key.data) # Resource ready? Schedule its tasks
|
||||||
|
self.debugger.after_io(self.clock() - before_time)
|
||||||
|
|
||||||
|
def awake_tasks(self):
|
||||||
|
"""
|
||||||
|
Reschedules paused tasks if their deadline
|
||||||
|
has elapsed
|
||||||
|
"""
|
||||||
|
|
||||||
|
while self.paused and self.paused.get_closest_deadline() <= self.clock():
|
||||||
|
# Reschedules tasks when their deadline has elapsed
|
||||||
|
task, _ = self.paused.get()
|
||||||
|
slept = self.clock() - task.paused_when
|
||||||
|
self.run_ready.append(task)
|
||||||
|
task.paused_when = 0
|
||||||
|
task.next_deadline = 0
|
||||||
|
self.debugger.after_sleep(task, slept)
|
||||||
|
|
||||||
|
def reschedule_running(self):
|
||||||
|
"""
|
||||||
|
Reschedules the currently running task
|
||||||
|
"""
|
||||||
|
|
||||||
|
if self.current_task:
|
||||||
|
self.run_ready.append(self.current_task)
|
||||||
|
else:
|
||||||
|
raise InternalError("aiosched is not running")
|
||||||
|
|
||||||
|
def suspend(self):
|
||||||
|
"""
|
||||||
|
Suspends execution of the current task. This is basically
|
||||||
|
a do-nothing method, since it will not reschedule the task
|
||||||
|
before returning. The task will stay suspended as long as
|
||||||
|
something else outside the loop reschedules it
|
||||||
|
"""
|
||||||
|
|
||||||
|
self.current_task.state = TaskState.PAUSED
|
||||||
|
|
||||||
|
def run_task_step(self):
|
||||||
|
"""
|
||||||
|
Runs a single step for the current task.
|
||||||
|
A step ends when the task awaits any of
|
||||||
|
our 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 the job for run()!
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Sets the currently running task
|
||||||
|
self.current_task = self.run_ready.popleft()
|
||||||
|
while self.current_task.done():
|
||||||
|
# We need to make sure we don't try to execute
|
||||||
|
# exited tasks that are on the running queue
|
||||||
|
self.current_task = self.run_ready.popleft()
|
||||||
|
self.debugger.before_task_step(self.current_task)
|
||||||
|
# Some debugging and internal chatter here
|
||||||
|
self.current_task.state = TaskState.RUN
|
||||||
|
self.current_task.steps += 1
|
||||||
|
if self.current_task.pending_cancellation:
|
||||||
|
# We perform the deferred cancellation
|
||||||
|
# if it was previously scheduled
|
||||||
|
self.cancel(self.current_task)
|
||||||
|
else:
|
||||||
|
# Run a single step with the calculation (i.e. until a yield
|
||||||
|
# somewhere)
|
||||||
|
method, args, kwargs = self.current_task.run(self.data.get(self.current_task))
|
||||||
|
self.data.pop(self.current_task, None)
|
||||||
|
if not hasattr(self, method) and not callable(getattr(self, method)):
|
||||||
|
# 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!
|
||||||
|
raise InternalError(
|
||||||
|
"Uh oh! Something very bad just happened, did you try to mix primitives from other async libraries?"
|
||||||
|
) from None
|
||||||
|
# Sneaky method call, thanks to David Beazley for this ;)
|
||||||
|
getattr(self, method)(*args, **kwargs)
|
||||||
|
self.debugger.after_task_step(self.current_task)
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
"""
|
||||||
|
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. This keeps the size of this module to
|
||||||
|
a minimum while allowing anyone to replace it with their
|
||||||
|
own, as long as the system calls required by higher-level
|
||||||
|
object wrappers are implemented. If you want to add features
|
||||||
|
to the library, don't add them here, but take inspiration
|
||||||
|
from the current API (i.e. not depending on any implementation
|
||||||
|
detail from the loop aside from system calls)
|
||||||
|
"""
|
||||||
|
|
||||||
|
while True:
|
||||||
|
if self.done():
|
||||||
|
# If we're done, which means there are
|
||||||
|
# both no paused tasks and no running tasks, we
|
||||||
|
# simply tear us down and return to self.start
|
||||||
|
self.shutdown()
|
||||||
|
break
|
||||||
|
elif not self.run_ready:
|
||||||
|
# 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
|
||||||
|
# or missing deadlines
|
||||||
|
if self.selector.get_map():
|
||||||
|
self.wait_io()
|
||||||
|
if self.paused:
|
||||||
|
# Next we check for deadlines
|
||||||
|
self.awake_tasks()
|
||||||
|
else:
|
||||||
|
# Otherwise, while there are tasks ready to run, we run them!
|
||||||
|
self.handle_task_run(self.run_task_step)
|
||||||
|
|
||||||
|
def start(self, func: Callable[..., Coroutine[Any, Any, Any]], *args, loop: bool = True) -> Any:
|
||||||
|
"""
|
||||||
|
Starts the event loop from a synchronous context. If the loop parameter
|
||||||
|
is false, the event loop will not start listening for events
|
||||||
|
automatically and the dispatching is on the users' shoulders
|
||||||
|
"""
|
||||||
|
|
||||||
|
entry_point = Task(func.__name__ or str(func), func(*args))
|
||||||
|
self.run_ready.append(entry_point)
|
||||||
|
self.debugger.on_start()
|
||||||
|
if loop:
|
||||||
|
try:
|
||||||
|
self.run()
|
||||||
|
finally:
|
||||||
|
self.debugger.on_exit()
|
||||||
|
if entry_point.exc:
|
||||||
|
raise entry_point.exc
|
||||||
|
return entry_point.result
|
||||||
|
|
||||||
|
def io_release(self, resource):
|
||||||
|
"""
|
||||||
|
Releases the given resource from our
|
||||||
|
selector
|
||||||
|
:param resource: The resource to be released
|
||||||
|
"""
|
||||||
|
|
||||||
|
if self.selector.get_map() and resource in self.selector.get_map():
|
||||||
|
self.selector.unregister(resource)
|
||||||
|
|
||||||
|
def io_release_task(self, task: Task):
|
||||||
|
"""
|
||||||
|
Calls self.io_release in a loop
|
||||||
|
for each I/O resource the given task owns
|
||||||
|
"""
|
||||||
|
|
||||||
|
for key in dict(self.selector.get_map()).values():
|
||||||
|
if task in key.data:
|
||||||
|
key.data.remove(task)
|
||||||
|
if not key.data:
|
||||||
|
self.selector.unregister(key.fileobj)
|
||||||
|
task.last_io = ()
|
||||||
|
|
||||||
|
def notify_closing(self, stream):
|
||||||
|
"""
|
||||||
|
Notifies paused tasks that a stream
|
||||||
|
is about to be closed. The stream
|
||||||
|
itself is not touched and must be
|
||||||
|
closed by the caller
|
||||||
|
"""
|
||||||
|
|
||||||
|
for k in filter(
|
||||||
|
lambda o: o.fileobj == stream,
|
||||||
|
dict(self.selector.get_map()).values(),
|
||||||
|
):
|
||||||
|
for task in k.data:
|
||||||
|
self.handle_task_run(partial(task.throw, ResourceClosed("stream has been closed")), task)
|
||||||
|
|
||||||
|
def cancel(self, task: Task):
|
||||||
|
"""
|
||||||
|
Schedules the task to be cancelled later
|
||||||
|
or does so straight away if it is safe to do so
|
||||||
|
"""
|
||||||
|
|
||||||
|
self.reschedule_running()
|
||||||
|
if task.done():
|
||||||
|
return
|
||||||
|
match task.state:
|
||||||
|
case TaskState.IO:
|
||||||
|
self.io_release_task(task)
|
||||||
|
case TaskState.PAUSED:
|
||||||
|
self.paused.discard(task)
|
||||||
|
case TaskState.INIT:
|
||||||
|
return
|
||||||
|
self.handle_task_run(partial(task.throw, Cancelled(task)), task)
|
||||||
|
if task.state == TaskState.CANCELLED:
|
||||||
|
self.debugger.after_cancel(task)
|
||||||
|
else:
|
||||||
|
task.pending_cancellation = True
|
||||||
|
|
||||||
|
def handle_task_run(self, func: Callable, task: Task | None = None):
|
||||||
|
"""
|
||||||
|
Convenience method for handling various exceptions
|
||||||
|
from tasks
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
func()
|
||||||
|
except StopIteration as ret:
|
||||||
|
# We re-define it because we call run_task_step
|
||||||
|
# with this method and that changes the current
|
||||||
|
# task
|
||||||
|
task = task or self.current_task
|
||||||
|
# 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 it has one. 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
|
||||||
|
task.state = TaskState.FINISHED
|
||||||
|
task.result = ret.value
|
||||||
|
self.wait(task)
|
||||||
|
except Cancelled:
|
||||||
|
# When a task needs to be cancelled, aiosched 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
|
||||||
|
# it for the next execution step of the task. aiosched will also make sure
|
||||||
|
# to re-raise cancellations at every checkpoint until the task lets the
|
||||||
|
# exception propagate into us, because we *really* want the task to be
|
||||||
|
# cancelled
|
||||||
|
task = task or self.current_task
|
||||||
|
task.state = TaskState.CANCELLED
|
||||||
|
task.pending_cancellation = False
|
||||||
|
self.wait(task)
|
||||||
|
except BaseException as err:
|
||||||
|
# Any other exception is caught here
|
||||||
|
task = task or self.current_task
|
||||||
|
task.exc = err
|
||||||
|
task.state = TaskState.CRASHED
|
||||||
|
self.wait(task)
|
||||||
|
|
||||||
|
def sleep(self, seconds: int | float):
|
||||||
|
"""
|
||||||
|
Puts the current task to sleep for a given amount of seconds
|
||||||
|
"""
|
||||||
|
|
||||||
|
if seconds:
|
||||||
|
self.debugger.before_sleep(self.current_task, seconds)
|
||||||
|
self.paused.put(self.current_task, seconds)
|
||||||
|
else:
|
||||||
|
# When we're called with a timeout of 0, this method acts as a checkpoint
|
||||||
|
# that allows aiosched 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
|
||||||
|
self.reschedule_running()
|
||||||
|
|
||||||
|
def wait(self, task: Task):
|
||||||
|
"""
|
||||||
|
Makes the current task wait for completion of the given one
|
||||||
|
"""
|
||||||
|
|
||||||
|
if task.done():
|
||||||
|
self.run_ready.extend(task.joiners)
|
||||||
|
task.joiners = {}
|
||||||
|
else:
|
||||||
|
task.joiners.add(self.current_task)
|
||||||
|
|
||||||
|
def spawn(self, func: Callable[..., Coroutine[Any, Any, Any]], *args, **kwargs):
|
||||||
|
"""
|
||||||
|
Spawns a task from a coroutine function. All positional and keyword arguments
|
||||||
|
besides the coroutine function itself are passed to the newly created coroutine
|
||||||
|
"""
|
||||||
|
|
||||||
|
task = Task(func.__name__ or repr(func), func(*args, **kwargs))
|
||||||
|
self.data[self.current_task] = task
|
||||||
|
self.run_ready.append(task)
|
||||||
|
self.reschedule_running()
|
||||||
|
|
||||||
|
def perform_io(self, resource, evt_type: int):
|
||||||
|
"""
|
||||||
|
Registers the given resource inside our selector to perform I/O multiplexing
|
||||||
|
|
||||||
|
:param resource: The resource 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 selectors.EVENT_READ or selectors.EVENT_WRITE
|
||||||
|
:type evt_type: int
|
||||||
|
"""
|
||||||
|
|
||||||
|
self.current_task.state = TaskState.IO
|
||||||
|
if self.current_task.last_io:
|
||||||
|
# Since, most of the time, tasks will perform multiple
|
||||||
|
# I/O operations on a given resource, 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 then tries to perform the same
|
||||||
|
# operation on the same resource again, then this method
|
||||||
|
# returns immediately as it is already being watched by
|
||||||
|
# the selector. If the resource is the same, but the
|
||||||
|
# event type has changed, then we modify the resource's
|
||||||
|
# associated event. Only if the resource is different from
|
||||||
|
# the last one used, then this method will register a new
|
||||||
|
# one
|
||||||
|
if self.current_task.last_io == (evt_type, resource):
|
||||||
|
# Socket is already listening for that event!
|
||||||
|
return
|
||||||
|
elif self.current_task.last_io[1] == resource:
|
||||||
|
# If the event to listen for has changed we just modify it
|
||||||
|
self.selector.modify(resource, evt_type, self.current_task)
|
||||||
|
self.current_task.last_io = (evt_type, resource)
|
||||||
|
elif not self.current_task.last_io or self.current_task.last_io[1] != resource:
|
||||||
|
# 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
|
||||||
|
self.current_task.last_io = evt_type, resource
|
||||||
|
try:
|
||||||
|
self.selector.register(resource, evt_type, [self.current_task])
|
||||||
|
except KeyError:
|
||||||
|
# The resource is already registered doing something else: we try
|
||||||
|
# to see if we can modify the event
|
||||||
|
key = self.selector.get_key(resource)
|
||||||
|
if evt_type != key.events:
|
||||||
|
self.selector.modify(
|
||||||
|
resource, evt_type | key.events, key.data + [self.current_task]
|
||||||
|
)
|
||||||
|
# If we get here, two tasks are trying to read or write on the same resource at the same time
|
||||||
|
raise ResourceBusy(
|
||||||
|
"The given resource is being read from/written to from another task"
|
||||||
|
) from None
|
|
@ -0,0 +1,86 @@
|
||||||
|
"""
|
||||||
|
aiosched: I'm bored and I'm making an async event loop again
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
https: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 inspect
|
||||||
|
from threading import local
|
||||||
|
from timeit import default_timer
|
||||||
|
from aiosched.kernel import FIFOKernel
|
||||||
|
from aiosched.errors import SchedulerError
|
||||||
|
from aiosched.util.debugging import BaseDebugger
|
||||||
|
from typing import Coroutine, Callable, Any
|
||||||
|
|
||||||
|
|
||||||
|
local_storage = local()
|
||||||
|
|
||||||
|
|
||||||
|
def get_event_loop():
|
||||||
|
"""
|
||||||
|
Returns the event loop associated to the current
|
||||||
|
thread
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
return local_storage.loop
|
||||||
|
except AttributeError:
|
||||||
|
raise SchedulerError("loop is not running") from None
|
||||||
|
|
||||||
|
|
||||||
|
def new_event_loop(clock_function: Callable, debugger: BaseDebugger | None = None):
|
||||||
|
"""
|
||||||
|
Associates a new event loop to the current thread
|
||||||
|
and deactivates the old one. This should not be
|
||||||
|
called explicitly unless you know what you're doing.
|
||||||
|
If an event loop is currently set, and it is running,
|
||||||
|
a SchedulerError exception is raised
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
loop = get_event_loop()
|
||||||
|
except SchedulerError:
|
||||||
|
local_storage.loop = FIFOKernel(clock_function, debugger)
|
||||||
|
else:
|
||||||
|
if not loop.done():
|
||||||
|
raise SchedulerError("cannot change event loop while running")
|
||||||
|
else:
|
||||||
|
loop.close()
|
||||||
|
local_storage.loop = FIFOKernel(clock_function, debugger)
|
||||||
|
|
||||||
|
|
||||||
|
def run(func: Callable[[Any, Any], Coroutine[Any, Any, Any]], debugger: BaseDebugger | None = None, *args, **kwargs):
|
||||||
|
"""
|
||||||
|
Starts the event loop from a synchronous entry point
|
||||||
|
"""
|
||||||
|
|
||||||
|
if inspect.iscoroutine(func):
|
||||||
|
raise SchedulerError(
|
||||||
|
"Looks like you tried to call aiosched.run(your_func(arg1, arg2, ...)), that is wrong!"
|
||||||
|
"\nWhat you wanna do, instead, is this: aiosched.run(your_func, arg1, arg2, ...)"
|
||||||
|
)
|
||||||
|
elif not inspect.iscoroutinefunction(func):
|
||||||
|
raise SchedulerError("aiosched.run() requires an async function as parameter!")
|
||||||
|
new_event_loop(kwargs.get("clock", default_timer), debugger)
|
||||||
|
get_event_loop().start(func, *args)
|
||||||
|
|
||||||
|
|
||||||
|
def clock() -> float:
|
||||||
|
"""
|
||||||
|
Returns the current clock time of the thread-local event
|
||||||
|
loop
|
||||||
|
"""
|
||||||
|
|
||||||
|
return get_event_loop().clock()
|
|
@ -0,0 +1,133 @@
|
||||||
|
"""
|
||||||
|
aiosched: I'm bored and I'm making an async event loop again
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
https: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 warnings
|
||||||
|
from enum import Enum, auto
|
||||||
|
from typing import Coroutine, Any
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
|
||||||
|
class TaskState(Enum):
|
||||||
|
"""
|
||||||
|
An enumeration of task states
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Task has been created and is
|
||||||
|
# ready to run
|
||||||
|
INIT: int = auto()
|
||||||
|
# Task is executing synchronous code
|
||||||
|
RUN: int = auto()
|
||||||
|
# Task is waiting on an I/O resource
|
||||||
|
IO: int = auto()
|
||||||
|
# Task is sleeping or waiting on an
|
||||||
|
# event
|
||||||
|
PAUSED: int = auto()
|
||||||
|
# Task has exited with an exception
|
||||||
|
CRASHED: int = auto()
|
||||||
|
# Task has been cancelled (either
|
||||||
|
# explicitly or implicitly)
|
||||||
|
CANCELLED: int = auto()
|
||||||
|
# Task has finished executing normally
|
||||||
|
FINISHED: int = auto()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Task:
|
||||||
|
|
||||||
|
"""
|
||||||
|
A simple wrapper around a coroutine object
|
||||||
|
"""
|
||||||
|
|
||||||
|
# The name of the task. Usually this equals self.coroutine.__name__,
|
||||||
|
# but it may fall back to repr(self.coroutine)
|
||||||
|
name: str
|
||||||
|
# The underlying coroutine object to wrap
|
||||||
|
coroutine: Coroutine
|
||||||
|
# This attribute will be None unless the task raised an error
|
||||||
|
exc: BaseException | None = None
|
||||||
|
# The return value of the coroutine
|
||||||
|
result: Any | None = None
|
||||||
|
# Task status
|
||||||
|
state: int = TaskState.INIT
|
||||||
|
# This attribute counts how many times the task's run() method has been called
|
||||||
|
steps: int = 0
|
||||||
|
# Simple optimization to improve the selector's efficiency. Stores the task's last
|
||||||
|
# I/O operation as well as a reference to the file descriptor it was performed on
|
||||||
|
last_io: tuple[int, Any] | None = None
|
||||||
|
# All the tasks waiting on this task's completion
|
||||||
|
joiners: set["Task"] = field(default_factory=set)
|
||||||
|
# Whether this task has a pending cancellation scheduled. This allows us to delay
|
||||||
|
# cancellation delivery as soon as the task calls another loop primitive
|
||||||
|
pending_cancellation: bool = False
|
||||||
|
# The time when the task was put on the waiting queue
|
||||||
|
paused_when: float = 0.0
|
||||||
|
# The next deadline, in terms of the absolute clock of the loop, associated to the task
|
||||||
|
next_deadline: float = 0.0
|
||||||
|
|
||||||
|
def run(self, what: Any | None = None):
|
||||||
|
"""
|
||||||
|
Simple abstraction layer over a coroutine's send method
|
||||||
|
|
||||||
|
:param what: The object that has to be sent to the coroutine,
|
||||||
|
defaults to None
|
||||||
|
:type what: Any, optional
|
||||||
|
"""
|
||||||
|
|
||||||
|
return self.coroutine.send(what)
|
||||||
|
|
||||||
|
def throw(self, err: BaseException):
|
||||||
|
"""
|
||||||
|
Simple abstraction layer over a coroutine's throw method
|
||||||
|
|
||||||
|
:param err: The exception that has to be raised inside
|
||||||
|
the task
|
||||||
|
:type err: BaseException
|
||||||
|
"""
|
||||||
|
|
||||||
|
self.exc = err
|
||||||
|
return self.coroutine.throw(err)
|
||||||
|
|
||||||
|
def __hash__(self):
|
||||||
|
"""
|
||||||
|
Implements hash(self)
|
||||||
|
"""
|
||||||
|
|
||||||
|
return hash(self.coroutine)
|
||||||
|
|
||||||
|
def done(self):
|
||||||
|
"""
|
||||||
|
Returns True if the task is not running,
|
||||||
|
False otherwise
|
||||||
|
"""
|
||||||
|
|
||||||
|
return self.state in [
|
||||||
|
TaskState.CANCELLED,
|
||||||
|
TaskState.CRASHED,
|
||||||
|
TaskState.FINISHED,
|
||||||
|
]
|
||||||
|
|
||||||
|
def __del__(self):
|
||||||
|
"""
|
||||||
|
Task destructor
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.coroutine.close()
|
||||||
|
except RuntimeError:
|
||||||
|
pass # TODO: This is kinda bad
|
||||||
|
if self.last_io:
|
||||||
|
warnings.warn(f"task '{self.name}' was destroyed, but has pending I/O")
|
|
@ -0,0 +1,194 @@
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from aiosched.task import Task
|
||||||
|
|
||||||
|
|
||||||
|
class BaseDebugger(ABC):
|
||||||
|
"""
|
||||||
|
The base for all debugger objects
|
||||||
|
"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def on_start(self):
|
||||||
|
"""
|
||||||
|
This method is called when the event
|
||||||
|
loop starts executing
|
||||||
|
"""
|
||||||
|
|
||||||
|
return NotImplemented
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def on_exit(self):
|
||||||
|
"""
|
||||||
|
This method is called when the event
|
||||||
|
loop exits entirely (all tasks completed)
|
||||||
|
"""
|
||||||
|
|
||||||
|
return NotImplemented
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def on_task_schedule(self, task: Task, delay: float):
|
||||||
|
"""
|
||||||
|
This method is called when a new task is
|
||||||
|
scheduled (not spawned)
|
||||||
|
|
||||||
|
:param task: The Task object representing a
|
||||||
|
aiosched Task and wrapping a coroutine
|
||||||
|
:type task: :class: aiosched.task.Task
|
||||||
|
:param delay: The delay, in seconds, after which
|
||||||
|
the task will start executing
|
||||||
|
:type delay: float
|
||||||
|
"""
|
||||||
|
|
||||||
|
return NotImplemented
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def on_task_spawn(self, task: Task):
|
||||||
|
"""
|
||||||
|
This method is called when a new task is
|
||||||
|
spawned
|
||||||
|
|
||||||
|
:param task: The Task object representing a
|
||||||
|
aiosched Task and wrapping a coroutine
|
||||||
|
:type task: :class: aiosched.task.Task
|
||||||
|
"""
|
||||||
|
|
||||||
|
return NotImplemented
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def on_task_exit(self, task: Task):
|
||||||
|
"""
|
||||||
|
This method is called when a task exits
|
||||||
|
|
||||||
|
:param task: The Task object representing an
|
||||||
|
aiosched Task and wrapping a coroutine
|
||||||
|
:type task: :class: aiosched.task.Task
|
||||||
|
"""
|
||||||
|
|
||||||
|
return NotImplemented
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def before_task_step(self, task: Task):
|
||||||
|
"""
|
||||||
|
This method is called right before
|
||||||
|
calling a task's run() method
|
||||||
|
|
||||||
|
:param task: The Task object representing an
|
||||||
|
aiosched Task and wrapping a coroutine
|
||||||
|
:type task: :class: aiosched.task.Task
|
||||||
|
"""
|
||||||
|
|
||||||
|
return NotImplemented
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def after_task_step(self, task: Task):
|
||||||
|
"""
|
||||||
|
This method is called right after
|
||||||
|
calling a task's run() method
|
||||||
|
|
||||||
|
:param task: The Task object representing an
|
||||||
|
aiosched Task and wrapping a coroutine
|
||||||
|
:type task: :class: aiosched.task.Task
|
||||||
|
"""
|
||||||
|
|
||||||
|
return NotImplemented
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def before_sleep(self, task: Task, seconds: float):
|
||||||
|
"""
|
||||||
|
This method is called before a task goes
|
||||||
|
to sleep
|
||||||
|
|
||||||
|
:param task: The Task object representing an
|
||||||
|
aiosched Task and wrapping a coroutine
|
||||||
|
:type task: :class: aiosched.task.Task
|
||||||
|
:param seconds: The amount of seconds the
|
||||||
|
task wants to sleep
|
||||||
|
:type seconds: int
|
||||||
|
"""
|
||||||
|
|
||||||
|
return NotImplemented
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def after_sleep(self, task: Task, seconds: float):
|
||||||
|
"""
|
||||||
|
This method is called after a tasks
|
||||||
|
awakes from sleeping
|
||||||
|
|
||||||
|
:param task: The Task object representing an
|
||||||
|
aiosched Task and wrapping a coroutine
|
||||||
|
:type task: :class: aiosched.task.Task
|
||||||
|
:param seconds: The amount of seconds the
|
||||||
|
task actually slept
|
||||||
|
:type seconds: float
|
||||||
|
"""
|
||||||
|
|
||||||
|
return NotImplemented
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def before_io(self, timeout: float):
|
||||||
|
"""
|
||||||
|
This method is called right before
|
||||||
|
the event loop checks for I/O events
|
||||||
|
|
||||||
|
:param timeout: The max. amount of seconds
|
||||||
|
that the loop will hang when using the select()
|
||||||
|
system call
|
||||||
|
:type timeout: float
|
||||||
|
"""
|
||||||
|
|
||||||
|
return NotImplemented
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def after_io(self, timeout: float):
|
||||||
|
"""
|
||||||
|
This method is called right after
|
||||||
|
the event loop has checked for I/O events
|
||||||
|
|
||||||
|
:param timeout: The actual amount of seconds
|
||||||
|
that the loop has hung when using the select()
|
||||||
|
system call
|
||||||
|
:type timeout: float
|
||||||
|
"""
|
||||||
|
|
||||||
|
return NotImplemented
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def before_cancel(self, task: Task):
|
||||||
|
"""
|
||||||
|
This method is called right before a task
|
||||||
|
gets cancelled
|
||||||
|
|
||||||
|
:param task: The Task object representing a
|
||||||
|
aiosched Task and wrapping a coroutine
|
||||||
|
:type task: :class: aiosched.task.Task
|
||||||
|
"""
|
||||||
|
|
||||||
|
return NotImplemented
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def after_cancel(self, task: Task) -> object:
|
||||||
|
"""
|
||||||
|
This method is called right after a task
|
||||||
|
gets cancelled
|
||||||
|
|
||||||
|
:param task: The Task object representing a
|
||||||
|
aiosched Task and wrapping a coroutine
|
||||||
|
:type task: :class: aiosched.task.Task
|
||||||
|
"""
|
||||||
|
|
||||||
|
return NotImplemented
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def on_exception_raised(self, task: Task, exc: BaseException):
|
||||||
|
"""
|
||||||
|
This method is called right after a task
|
||||||
|
has raised an exception
|
||||||
|
|
||||||
|
:param task: The Task object representing a
|
||||||
|
aiosched Task and wrapping a coroutine
|
||||||
|
:type task: :class: aiosched.task.Task
|
||||||
|
:param exc: The exception that was raised
|
||||||
|
:type exc: BaseException
|
||||||
|
"""
|
||||||
|
|
||||||
|
return NotImplemented
|
|
@ -0,0 +1,23 @@
|
||||||
|
import setuptools
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
with open("README.md", "r") as readme:
|
||||||
|
long_description = readme.read()
|
||||||
|
setuptools.setup(
|
||||||
|
name="aiosched",
|
||||||
|
version="0.1.0",
|
||||||
|
author="Nocturn9x",
|
||||||
|
author_email="hackhab@gmail.com",
|
||||||
|
description="An asynchronous scheduler",
|
||||||
|
long_description=long_description,
|
||||||
|
long_description_content_type="text/markdown",
|
||||||
|
url="https://git.nocturn9x.space/nocturn9x/aiosched",
|
||||||
|
packages=setuptools.find_packages(),
|
||||||
|
classifiers=[
|
||||||
|
"Programming Language :: Python :: 3",
|
||||||
|
"Operating System :: OS Independent",
|
||||||
|
"License :: OSI Approved :: Apache License 2.0",
|
||||||
|
],
|
||||||
|
python_requires=">=3.8",
|
||||||
|
)
|
|
@ -0,0 +1,36 @@
|
||||||
|
import random
|
||||||
|
import aiosched
|
||||||
|
from debugger import Debugger
|
||||||
|
|
||||||
|
|
||||||
|
async def child(name: str, n: int):
|
||||||
|
before = aiosched.clock()
|
||||||
|
print(f"[child {name}] Sleeping for {n} seconds")
|
||||||
|
try:
|
||||||
|
await aiosched.sleep(n)
|
||||||
|
except aiosched.errors.Cancelled:
|
||||||
|
print(f"[child {name}] Oh no, I've been cancelled!")
|
||||||
|
raise # We re-raise, or things break
|
||||||
|
print(f"[child {name}] Done! Slept for {aiosched.clock() - before:.2f} seconds")
|
||||||
|
|
||||||
|
|
||||||
|
async def main(children: list[tuple[str, int]]):
|
||||||
|
tasks: list[aiosched.task.Task] = []
|
||||||
|
print("[main] Spawning children")
|
||||||
|
for name, delay in children:
|
||||||
|
tasks.append(await aiosched.spawn(child, name, delay))
|
||||||
|
print(f"[main] Spawned {len(tasks)} children")
|
||||||
|
print(f"[main] Cancelling a random child")
|
||||||
|
cancelled = random.choice(tasks)
|
||||||
|
await aiosched.cancel(cancelled)
|
||||||
|
tasks.remove(cancelled)
|
||||||
|
print(f"[main] Waiting for {len(tasks)} children")
|
||||||
|
before = aiosched.clock()
|
||||||
|
for i, task in enumerate(tasks):
|
||||||
|
print(f"[main] Waiting for child #{i + 1}")
|
||||||
|
await aiosched.wait(task)
|
||||||
|
print(f"[main] Child #{i + 1} has exited")
|
||||||
|
print(f"[main] Children exited in {aiosched.clock() - before:.2f} seconds")
|
||||||
|
|
||||||
|
|
||||||
|
aiosched.run(main, None, [("first", 1), ("second", 2), ("third", 3)])
|
|
@ -0,0 +1,25 @@
|
||||||
|
import aiosched
|
||||||
|
from debugger import Debugger
|
||||||
|
|
||||||
|
|
||||||
|
async def child(name: str, n: int):
|
||||||
|
before = aiosched.clock()
|
||||||
|
print(f"[child {name}] Sleeping for {n} seconds")
|
||||||
|
await aiosched.sleep(n)
|
||||||
|
print(f"[child {name}] Done! Slept for {aiosched.clock() - before:.2f} seconds")
|
||||||
|
raise TypeError("waa")
|
||||||
|
|
||||||
|
|
||||||
|
async def main(n: int):
|
||||||
|
print("[main] Spawning child")
|
||||||
|
task = await aiosched.spawn(child, "raise", n)
|
||||||
|
print("[main] Waiting for child")
|
||||||
|
before = aiosched.clock()
|
||||||
|
try:
|
||||||
|
await aiosched.wait(task)
|
||||||
|
except Exception as err:
|
||||||
|
print(f"[main] Child raised an exception -> {type(err).__name__}: {err}")
|
||||||
|
print(f"[main] Child exited in {aiosched.clock() - before:.2f} seconds")
|
||||||
|
|
||||||
|
|
||||||
|
aiosched.run(main, None, 5)
|
|
@ -0,0 +1,51 @@
|
||||||
|
from aiosched.util.debugging import BaseDebugger
|
||||||
|
|
||||||
|
|
||||||
|
class Debugger(BaseDebugger):
|
||||||
|
"""
|
||||||
|
A simple debugger for aiosched
|
||||||
|
"""
|
||||||
|
|
||||||
|
def on_start(self):
|
||||||
|
print("## Started running")
|
||||||
|
|
||||||
|
def on_exit(self):
|
||||||
|
print("## Finished running")
|
||||||
|
|
||||||
|
def on_task_schedule(self, task, delay: int):
|
||||||
|
print(f">> A task named '{task.name}' was scheduled to run in {delay:.2f} seconds")
|
||||||
|
|
||||||
|
def on_task_spawn(self, task):
|
||||||
|
print(f">> A task named '{task.name}' was spawned")
|
||||||
|
|
||||||
|
def on_task_exit(self, task):
|
||||||
|
print(f"<< Task '{task.name}' exited")
|
||||||
|
|
||||||
|
def before_task_step(self, task):
|
||||||
|
print(f"-> About to run a step for '{task.name}'")
|
||||||
|
|
||||||
|
def after_task_step(self, task):
|
||||||
|
print(f"<- Ran a step for '{task.name}'")
|
||||||
|
|
||||||
|
def before_sleep(self, task, seconds):
|
||||||
|
print(f"# About to put '{task.name}' to sleep for {seconds:.2f} seconds")
|
||||||
|
|
||||||
|
def after_sleep(self, task, seconds):
|
||||||
|
print(f"# Task '{task.name}' slept for {seconds:.2f} seconds")
|
||||||
|
|
||||||
|
def before_io(self, timeout):
|
||||||
|
if timeout is None:
|
||||||
|
timeout = float("inf")
|
||||||
|
print(f"!! About to check for I/O for up to {timeout:.2f} seconds")
|
||||||
|
|
||||||
|
def after_io(self, timeout):
|
||||||
|
print(f"!! Done I/O check (waited for {timeout:.2f} seconds)")
|
||||||
|
|
||||||
|
def before_cancel(self, task):
|
||||||
|
print(f"// About to cancel '{task.name}'")
|
||||||
|
|
||||||
|
def after_cancel(self, task):
|
||||||
|
print(f"// Cancelled '{task.name}'")
|
||||||
|
|
||||||
|
def on_exception_raised(self, task, exc):
|
||||||
|
print(f"== '{task.name}' raised {repr(exc)}")
|
|
@ -0,0 +1,27 @@
|
||||||
|
import aiosched
|
||||||
|
from debugger import Debugger
|
||||||
|
|
||||||
|
|
||||||
|
async def child(name: str, n: int):
|
||||||
|
before = aiosched.clock()
|
||||||
|
print(f"[child {name}] Sleeping for {n} seconds")
|
||||||
|
await aiosched.sleep(n)
|
||||||
|
print(f"[child {name}] Done! Slept for {aiosched.clock() - before:.2f} seconds")
|
||||||
|
|
||||||
|
|
||||||
|
async def main(children: list[tuple[str, int]]):
|
||||||
|
tasks: list[aiosched.task.Task] = []
|
||||||
|
print("[main] Spawning children")
|
||||||
|
for name, delay in children:
|
||||||
|
tasks.append(await aiosched.spawn(child, name, delay))
|
||||||
|
print(f"[main] Spawned {len(tasks)} children")
|
||||||
|
before = aiosched.clock()
|
||||||
|
print(f"[main] Waiting for {len(tasks)} children")
|
||||||
|
for i, task in enumerate(tasks):
|
||||||
|
print(f"[main] Waiting for child #{i + 1}")
|
||||||
|
await aiosched.wait(task)
|
||||||
|
print(f"[main] Child #{i + 1} has exited")
|
||||||
|
print(f"[main] Children exited in {aiosched.clock() - before:.2f} seconds")
|
||||||
|
|
||||||
|
|
||||||
|
aiosched.run(main, Debugger(), [("first", 1), ("second", 2), ("third", 3)])
|
Reference in New Issue