Skip to content

Commit 79bae0c

Browse files
authored
Add loop.call_soon_threadsafe() and re-implement precise sleep (#146)
1 parent d8b2c57 commit 79bae0c

File tree

12 files changed

+395
-99
lines changed

12 files changed

+395
-99
lines changed

rendercanvas/_coreutils.py

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,19 @@
66
import re
77
import sys
88
import time
9+
import queue
910
import weakref
1011
import logging
12+
import threading
1113
import ctypes.util
1214
from contextlib import contextmanager
15+
from collections import namedtuple
16+
17+
18+
# %% Constants
19+
20+
21+
IS_WIN = sys.platform.startswith("win") # Note that IS_WIN is false on Pyodide
1322

1423

1524
# %% Logging
@@ -93,6 +102,132 @@ def proxy(*args, **kwargs):
93102
return proxy
94103

95104

105+
# %% Helper for scheduling call-laters
106+
107+
108+
class CallLaterThread(threading.Thread):
109+
"""An object that can be used to do "call later" from a dedicated thread.
110+
111+
This is helpful to implement a call-later mechanism on some backends, and
112+
serves as an alternative timeout mechanism in Windows (to overcome its
113+
notorious 15.6ms ticks).
114+
115+
Windows historically uses ticks that go at 64 ticks per second, i.e. 15.625
116+
ms each. Other platforms are "tickless" and (in theory) have microsecond
117+
resolution.
118+
119+
Care is taken to realize precise timing, in the order of 1 ms. Nevertheless,
120+
on OS's other than Windows, the native timers are more accurate than this
121+
threaded approach. I suspect that this is related to the GIL; two threads
122+
cannot run at the same time.
123+
"""
124+
125+
Item = namedtuple("Item", ["time", "index", "callback", "args"])
126+
127+
def __init__(self):
128+
super().__init__()
129+
self._queue = queue.SimpleQueue()
130+
self._count = 0
131+
self.daemon = True # don't let this thread prevent shutdown
132+
self.start()
133+
134+
def call_later_from_thread(self, delay, callback, *args):
135+
"""In delay seconds, call the callback from the scheduling thread."""
136+
self._count += 1
137+
item = CallLaterThread.Item(
138+
time.perf_counter() + float(delay), self._count, callback, args
139+
)
140+
self._queue.put(item)
141+
142+
def run(self):
143+
perf_counter = time.perf_counter
144+
Empty = queue.Empty # noqa: N806
145+
q = self._queue
146+
priority = []
147+
is_win = IS_WIN
148+
149+
wait_until = None
150+
timestep = 0.001 # for doing small sleeps
151+
leeway = timestep / 2 # a little offset so waiting exactly right on average
152+
leeway += 0.0005 # extra offset to account for GIL etc. (0.5ms seems ok)
153+
154+
while True:
155+
# == Wait for input
156+
157+
if wait_until is None:
158+
# Nothing to do but wait
159+
new_item = q.get(True, None)
160+
else:
161+
# We wait for the queue with a timeout. But because the timeout is not very precise,
162+
# we wait shorter, and then go in a loop with some hard sleeps.
163+
# Windows has 15.6 ms resolution ticks. But also on other OSes,
164+
# it benefits precision to do the last bit with hard sleeps.
165+
offset = 0.016 if is_win else timestep
166+
try:
167+
new_item = q.get(True, max(0, wait_until - perf_counter() - offset))
168+
except Empty:
169+
new_item = None
170+
while perf_counter() < wait_until:
171+
time.sleep(timestep)
172+
try:
173+
new_item = q.get_nowait()
174+
break
175+
except Empty:
176+
pass
177+
178+
# Put it in our priority queue
179+
if new_item is not None:
180+
priority.append(new_item)
181+
priority.sort(reverse=True)
182+
183+
del new_item
184+
185+
# == Process items until we have to wait
186+
187+
item = None
188+
while True:
189+
# Get item that is up next
190+
try:
191+
item = priority.pop(-1)
192+
except IndexError:
193+
wait_until = None
194+
break
195+
196+
# If it's not yet time for the item, put it back, and go wait
197+
item_time_threshold = item.time - leeway
198+
if perf_counter() < item_time_threshold:
199+
priority.append(item)
200+
wait_until = item_time_threshold
201+
break
202+
203+
# Otherwise, handle the callback
204+
try:
205+
item.callback(*item.args)
206+
except Exception as err:
207+
logger.error(f"Error in CallLaterThread callback: {err}")
208+
209+
del item
210+
211+
212+
_call_later_thread = None
213+
214+
215+
def call_later_from_thread(delay: float, callback: object, *args: object):
216+
"""Utility that calls a callback after a specified delay, from a separate thread.
217+
218+
The caller is responsible for the given callback to be thread-safe.
219+
There is one global thread that handles all callbacks. This thread is spawned the first time
220+
that this function is called.
221+
222+
Note that this function should only be used in environments where threading is available.
223+
E.g. on Pyodide this will raise ``RuntimeError: can't start new thread``.
224+
"""
225+
global _call_later_thread
226+
if _call_later_thread is None:
227+
_call_later_thread = CallLaterThread()
228+
return _call_later_thread.call_later_from_thread(delay, callback, *args)
229+
230+
96231
# %% lib support
97232

98233

rendercanvas/_loop.py

Lines changed: 46 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from inspect import iscoroutinefunction
99
from typing import TYPE_CHECKING
1010

11-
from ._coreutils import logger, log_exception
11+
from ._coreutils import logger, log_exception, call_later_from_thread
1212
from .utils.asyncs import sleep
1313
from .utils import asyncadapter
1414

@@ -29,7 +29,7 @@ class BaseLoop:
2929
"""The base class for an event-loop object.
3030
3131
Canvas backends can implement their own loop subclass (like qt and wx do), but a
32-
canvas backend can also rely on one of muliple loop implementations (like glfw
32+
canvas backend can also rely on one of multiple loop implementations (like glfw
3333
running on asyncio or trio).
3434
3535
The lifecycle states of a loop are:
@@ -46,7 +46,7 @@ class BaseLoop:
4646
* Stopping the loop (via ``.stop()``) closes the canvases, which will then stop the loop.
4747
* From there it can go back to the ready state (which would call ``_rc_init()`` again).
4848
* In backends like Qt, the native loop can be started without us knowing: state "active".
49-
* In interactive settings like an IDE that runs an syncio or Qt loop, the
49+
* In interactive settings like an IDE that runs an asyncio or Qt loop, the
5050
loop can become "active" as soon as the first canvas is created.
5151
5252
"""
@@ -176,8 +176,11 @@ async def wrapper():
176176
def call_soon(self, callback: CallbackFunction, *args: Any) -> None:
177177
"""Arrange for a callback to be called as soon as possible.
178178
179-
The callback will be called in the next iteration of the event-loop,
180-
but other pending events/callbacks may be handled first. Returns None.
179+
The callback will be called in the next iteration of the event-loop, but
180+
other pending events/callbacks may be handled first. Returns None.
181+
182+
Not thread-safe; use ``call_soon_threadsafe()`` for scheduling callbacks
183+
from another thread.
181184
"""
182185
if not callable(callback):
183186
raise TypeError("call_soon() expects a callable.")
@@ -190,6 +193,22 @@ async def wrapper():
190193

191194
self._rc_add_task(wrapper, "call_soon")
192195

196+
def call_soon_threadsafe(self, callback: CallbackFunction, *args: Any) -> None:
197+
"""A thread-safe variant of ``call_soon()``."""
198+
199+
if not callable(callback):
200+
raise TypeError("call_soon_threadsafe() expects a callable.")
201+
elif iscoroutinefunction(callback):
202+
raise TypeError(
203+
"call_soon_threadsafe() expects a normal callable, not an async one."
204+
)
205+
206+
def wrapper():
207+
with log_exception("Callback error:"):
208+
callback(*args)
209+
210+
self._rc_call_soon_threadsafe(wrapper)
211+
193212
def call_later(self, delay: float, callback: CallbackFunction, *args: Any) -> None:
194213
"""Arrange for a callback to be called after the given delay (in seconds)."""
195214
if delay <= 0:
@@ -214,7 +233,7 @@ def run(self) -> None:
214233
its fine to start the loop in the normal way.
215234
216235
This call usually blocks, but it can also return immediately, e.g. when there are no
217-
canvases, or when the loop is already active (e.g. interactve via IDE).
236+
canvases, or when the loop is already active (e.g. interactive via IDE).
218237
"""
219238

220239
# Can we enter the loop?
@@ -360,8 +379,13 @@ def _rc_stop(self):
360379
def _rc_add_task(self, async_func, name):
361380
"""Add an async task to the running loop.
362381
363-
This method is optional. A subclass must either implement ``_rc_add_task`` or ``_rc_call_later``.
382+
True async loop-backends (like asyncio and trio) should implement this.
383+
When they do, ``_rc_call_later`` is not used.
384+
385+
Other loop-backends can use the default implementation, which uses the
386+
``asyncadapter`` which runs coroutines using ``_rc_call_later``.
364387
388+
* If you implement this, make ``_rc_call_later()`` raise an exception.
365389
* Schedule running the task defined by the given co-routine function.
366390
* The name is for debugging purposes only.
367391
* The subclass is responsible for cancelling remaining tasks in _rc_stop.
@@ -374,11 +398,23 @@ def _rc_add_task(self, async_func, name):
374398
def _rc_call_later(self, delay, callback):
375399
"""Method to call a callback in delay number of seconds.
376400
377-
This method is optional. A subclass must either implement ``_rc_add_task`` or ``_rc_call_later``.
401+
Backends that implement ``_rc_add_task`` should not implement this.
402+
Other backends can use the default implementation, which uses a
403+
scheduler thread and ``_rc_call_soon_threadsafe``. But they can also
404+
implement this using the loop-backend's own mechanics.
378405
379-
* If you implememt this, make ``_rc_add_task()`` call ``super()._rc_add_task()``.
406+
* If you implement this, make ``_rc_add_task()`` call ``super()._rc_add_task()``.
407+
* Take into account that on Windows, timers are usually inaccurate.
380408
* If delay is zero, this should behave like "call_soon".
381-
* No need to catch errors from the callback; that's dealt with internally.
409+
* No need to catch errors from the callback; that's dealt with
410+
internally.
382411
* Return None.
383412
"""
413+
call_later_from_thread(delay, self._rc_call_soon_threadsafe, callback)
414+
415+
def _rc_call_soon_threadsafe(self, callback):
416+
"""Method to schedule a callback in the loop's thread.
417+
418+
Must be thread-safe; this may be called from a different thread.
419+
"""
384420
raise NotImplementedError()

rendercanvas/_scheduler.py

Lines changed: 3 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,13 @@
22
The scheduler class/loop.
33
"""
44

5-
import sys
65
import time
76
import weakref
87

98
from ._enums import UpdateMode
109
from .utils.asyncs import sleep, Event
1110

1211

13-
IS_WIN = sys.platform.startswith("win")
14-
15-
1612
class Scheduler:
1713
"""Helper class to schedule event processing and drawing."""
1814

@@ -121,20 +117,9 @@ async def __scheduler_task(self):
121117
# Determine amount of sleep
122118
sleep_time = delay - (time.perf_counter() - last_tick_time)
123119

124-
if IS_WIN:
125-
# On Windows OS-level timers have an in accuracy of 15.6 ms.
126-
# This can cause sleep to take longer than intended. So we sleep
127-
# less, and then do a few small sync-sleeps that have high accuracy.
128-
await sleep(max(0, sleep_time - 0.0156))
129-
sleep_time = delay - (time.perf_counter() - last_tick_time)
130-
while sleep_time > 0:
131-
time.sleep(min(sleep_time, 0.001)) # sleep hard for at most 1ms
132-
await sleep(0) # Allow other tasks to run but don't wait
133-
sleep_time = delay - (time.perf_counter() - last_tick_time)
134-
else:
135-
# Wait. Even if delay is zero, it gives control back to the loop,
136-
# allowing other tasks to do work.
137-
await sleep(max(0, sleep_time))
120+
# Wait. Even if delay is zero, it gives control back to the loop,
121+
# allowing other tasks to do work.
122+
await sleep(max(0, sleep_time))
138123

139124
# Below is the "tick"
140125

rendercanvas/asyncio.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,8 +88,12 @@ def _rc_add_task(self, func, name):
8888
self.__tasks.add(task)
8989
task.add_done_callback(self.__tasks.discard)
9090

91-
def _rc_call_later(self, *args):
91+
def _rc_call_later(self, delay, callback):
9292
raise NotImplementedError() # we implement _rc_add_task instead
9393

94+
def _rc_call_soon_threadsafe(self, callback):
95+
loop = self._interactive_loop or self._run_loop
96+
loop.call_soon_threadsafe(callback)
97+
9498

9599
loop = AsyncioLoop()

rendercanvas/offscreen.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,5 +172,8 @@ def _rc_add_task(self, async_func, name):
172172
def _rc_call_later(self, delay, callback):
173173
self._callbacks.append((time.perf_counter() + delay, callback))
174174

175+
def _rc_call_soon_threadsafe(self, callback):
176+
self._callbacks.append((0, callback))
177+
175178

176179
loop = StubLoop()

0 commit comments

Comments
 (0)