Skip to content

Commit e97fdea

Browse files
committed
Enable limited use of asyncio with executors
Signed-off-by: Shane Loretz <sloretz@osrfoundation.org>
1 parent c4cf7a0 commit e97fdea

7 files changed

Lines changed: 200 additions & 122 deletions

File tree

rclpy/CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,7 @@ if(BUILD_TESTING)
159159
test/test_action_client.py
160160
test/test_action_graph.py
161161
test/test_action_server.py
162+
test/test_asyncio_interop.py
162163
test/test_callback_group.py
163164
test/test_client.py
164165
test/test_clock.py

rclpy/rclpy/executors.py

Lines changed: 35 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,27 @@ def create_task(self, callback: Union[Callable, Coroutine], *args, **kwargs) ->
190190
# Task inherits from Future
191191
return task
192192

193+
def call_soon(self, callback, *args) -> Task:
194+
"""
195+
Add a callback or coroutine to be executed during :meth:`spin`.
196+
197+
Arguments to this function are passed to the callback.
198+
199+
:param callback: A callback to be run in the executor.
200+
:return: A Task which the executor will execute.
201+
"""
202+
if self._is_shutdown:
203+
raise ShutdownException()
204+
205+
if not isinstance(callback, Task):
206+
callback = Task(callback, args, None, executor=self)
207+
208+
with self._tasks_lock:
209+
self._tasks.append((callback, None, None))
210+
self._guard.trigger()
211+
212+
return callback
213+
193214
def shutdown(self, timeout_sec: float = None) -> bool:
194215
"""
195216
Stop executing callbacks and wait for their completion.
@@ -432,12 +453,9 @@ async def handler(entity, gc, is_shutdown, work_tracker):
432453
gc.trigger()
433454
except InvalidHandle:
434455
pass
435-
task = Task(
456+
return Task(
436457
handler, (entity, self._guard, self._is_shutdown, self._work_tracker),
437458
executor=self)
438-
with self._tasks_lock:
439-
self._tasks.append((task, entity, node))
440-
return task
441459

442460
def can_execute(self, entity: WaitableEntityType) -> bool:
443461
"""
@@ -481,16 +499,19 @@ def _wait_for_ready_callbacks(
481499
# Yield tasks in-progress before waiting for new work
482500
tasks = None
483501
with self._tasks_lock:
484-
tasks = list(self._tasks)
485-
if tasks:
486-
for task, entity, node in reversed(tasks):
487-
if (not task.executing() and not task.done() and
488-
(node is None or node in nodes_to_use)):
489-
yielded_work = True
490-
yield task, entity, node
491-
with self._tasks_lock:
492-
# Get rid of any tasks that are done
493-
self._tasks = list(filter(lambda t_e_n: not t_e_n[0].done(), self._tasks))
502+
tasks = self._tasks
503+
# Tasks that need to be executed again will add themselves back to the executor
504+
self._tasks = []
505+
while tasks:
506+
task_trio = tasks.pop()
507+
task, entity, node = task_trio
508+
if node is None or node in nodes_to_use:
509+
yielded_work = True
510+
yield task_trio
511+
else:
512+
# Asked not to execute these tasks, so don't do them yet
513+
with self._tasks_lock:
514+
self._tasks.append(task_trio)
494515

495516
# Gather entities that can be waited on
496517
subscriptions: List[Subscription] = []

rclpy/rclpy/task.py

Lines changed: 55 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15+
import asyncio
1516
import inspect
1617
import sys
1718
import threading
@@ -52,11 +53,12 @@ def __del__(self):
5253
file=sys.stderr)
5354

5455
def __await__(self):
55-
# Yield if the task is not finished
56-
while not self._done:
57-
yield
56+
if not self._done:
57+
yield self
5858
return self.result()
5959

60+
__iter__ = __await__
61+
6062
def cancel(self):
6163
"""Request cancellation of the running task if it is not done already."""
6264
with self._lock:
@@ -142,7 +144,7 @@ def _schedule_or_invoke_done_callbacks(self):
142144
if executor is not None:
143145
# Have the executor take care of the callbacks
144146
for callback in callbacks:
145-
executor.create_task(callback, self)
147+
executor.call_soon(callback, self)
146148
else:
147149
# No executor, call right away
148150
for callback in callbacks:
@@ -176,7 +178,7 @@ def add_done_callback(self, callback):
176178
if self._done:
177179
executor = self._executor()
178180
if executor is not None:
179-
executor.create_task(callback, self)
181+
executor.call_soon(callback, self)
180182
else:
181183
invoke = True
182184
else:
@@ -199,6 +201,8 @@ class Task(Future):
199201

200202
def __init__(self, handler, args=None, kwargs=None, executor=None):
201203
super().__init__(executor=executor)
204+
if executor is None:
205+
raise RuntimeError('Task requires an executor')
202206
# _handler is either a normal function or a coroutine
203207
self._handler = handler
204208
# Arguments passed into the function
@@ -212,62 +216,66 @@ def __init__(self, handler, args=None, kwargs=None, executor=None):
212216
self._handler = handler(*args, **kwargs)
213217
self._args = None
214218
self._kwargs = None
215-
# True while the task is being executed
216-
self._executing = False
217-
# Lock acquired to prevent task from executing in parallel with itself
218-
self._task_lock = threading.Lock()
219219

220-
def __call__(self):
220+
def __call__(self, future=None):
221221
"""
222222
Run or resume a task.
223223
224224
This attempts to execute a handler. If the handler is a coroutine it will attempt to
225225
await it. If there are done callbacks it will schedule them with the executor.
226226
227227
The return value of the handler is stored as the task result.
228+
229+
This function must not be called in parallel with itself.
230+
231+
:param future: do not use
228232
"""
229-
if self._done or self._executing or not self._task_lock.acquire(blocking=False):
233+
if self._done:
230234
return
231-
try:
232-
if self._done:
233-
return
234-
self._executing = True
235-
236-
if inspect.iscoroutine(self._handler):
237-
# Execute a coroutine
238-
try:
239-
self._handler.send(None)
240-
except StopIteration as e:
241-
# The coroutine finished; store the result
242-
self._handler.close()
243-
self.set_result(e.value)
244-
self._complete_task()
245-
except Exception as e:
246-
self.set_exception(e)
247-
self._complete_task()
248-
else:
249-
# Execute a normal function
250-
try:
251-
self.set_result(self._handler(*self._args, **self._kwargs))
252-
except Exception as e:
253-
self.set_exception(e)
235+
if inspect.iscoroutine(self._handler):
236+
# Execute a coroutine
237+
try:
238+
result = self._handler.send(None)
239+
if isinstance(result, Future):
240+
# Wait for an rclpy future to complete
241+
result.add_done_callback(self)
242+
elif asyncio.isfuture(result):
243+
# Get the event loop of this thread (raises RuntimeError if there isn't one)
244+
event_loop = asyncio.get_running_loop()
245+
# Make sure we're in the same thread as the future's event loop.
246+
# TODO(sloretz) is asyncio.Future.get_loop() thread-safe?
247+
if result.get_loop() is not event_loop:
248+
raise RuntimeError('Cannot await asyncio future from a different thread')
249+
# Resume this task when the asyncio future completes
250+
result.add_done_callback(lambda _: self._executor().call_soon(self))
251+
elif result is None:
252+
# Wait for one iteration if a bare yield is used
253+
self._executor().call_soon(self)
254+
else:
255+
# What is this intermediate value?
256+
# Could be a different async library's coroutine
257+
# Could be a generator yielded a value
258+
raise RuntimeError(f'Coroutine yielded unexpected value: {result}')
259+
except StopIteration as e:
260+
# Coroutine or generator returning a result
261+
self._handler.close()
262+
self.set_result(e.value)
254263
self._complete_task()
255-
256-
self._executing = False
257-
finally:
258-
self._task_lock.release()
264+
except Exception as e:
265+
# Coroutine or generator raising an exception
266+
self._handler.close()
267+
self.set_exception(e)
268+
self._complete_task()
269+
else:
270+
# Execute a normal function
271+
try:
272+
self.set_result(self._handler(*self._args, **self._kwargs))
273+
except Exception as e:
274+
self.set_exception(e)
275+
self._complete_task()
259276

260277
def _complete_task(self):
261278
"""Cleanup after task finished."""
262279
self._handler = None
263280
self._args = None
264281
self._kwargs = None
265-
266-
def executing(self):
267-
"""
268-
Check if the task is currently being executed.
269-
270-
:return: True if the task is currently executing.
271-
:rtype: bool
272-
"""
273-
return self._executing

rclpy/test/test_asyncio_interop.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
# Copyright 2022 Open Source Robotics Foundation, Inc.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
import asyncio
16+
import time
17+
18+
import pytest
19+
20+
import rclpy
21+
from rclpy.executors import SingleThreadedExecutor
22+
23+
24+
MAX_TEST_TIME = 5.0
25+
TIME_FUDGE_FACTOR = 0.2
26+
27+
28+
@pytest.fixture
29+
def node_and_executor():
30+
rclpy.init()
31+
node = rclpy.create_node('test_asyncio_interop')
32+
executor = SingleThreadedExecutor()
33+
executor.add_node(node)
34+
yield node, executor
35+
executor.shutdown()
36+
node.destroy_node()
37+
rclpy.shutdown()
38+
39+
40+
def test_sleep_in_event_loop(node_and_executor):
41+
node, executor = node_and_executor
42+
43+
expected_sleep_time = 0.5
44+
sleep_time = None
45+
46+
async def cb():
47+
nonlocal sleep_time
48+
start = time.monotonic()
49+
await asyncio.sleep(expected_sleep_time)
50+
end = time.monotonic()
51+
sleep_time = end - start
52+
53+
guard = node.create_guard_condition(cb)
54+
guard.trigger()
55+
56+
async def spin():
57+
nonlocal sleep_time
58+
start = time.monotonic()
59+
while not sleep_time and MAX_TEST_TIME > time.monotonic() - start:
60+
executor.spin_once(timeout_sec=0)
61+
# Don't use 100% CPU
62+
await asyncio.sleep(0.01)
63+
64+
asyncio.run(spin())
65+
assert sleep_time >= expected_sleep_time
66+
assert abs(expected_sleep_time - sleep_time) <= expected_sleep_time * TIME_FUDGE_FACTOR

rclpy/test/test_executor.py

Lines changed: 0 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15-
import asyncio
1615
import threading
1716
import time
1817
import unittest
@@ -157,25 +156,15 @@ def test_execute_coroutine_timer(self):
157156
executor.add_node(self.node)
158157

159158
called1 = False
160-
called2 = False
161159

162160
async def coroutine():
163161
nonlocal called1
164-
nonlocal called2
165162
called1 = True
166-
await asyncio.sleep(0)
167-
called2 = True
168163

169164
tmr = self.node.create_timer(0.1, coroutine)
170165
try:
171166
executor.spin_once(timeout_sec=1.23)
172167
self.assertTrue(called1)
173-
self.assertFalse(called2)
174-
175-
called1 = False
176-
executor.spin_once(timeout_sec=0)
177-
self.assertFalse(called1)
178-
self.assertTrue(called2)
179168
finally:
180169
self.node.destroy_timer(tmr)
181170

@@ -185,26 +174,16 @@ def test_execute_coroutine_guard_condition(self):
185174
executor.add_node(self.node)
186175

187176
called1 = False
188-
called2 = False
189177

190178
async def coroutine():
191179
nonlocal called1
192-
nonlocal called2
193180
called1 = True
194-
await asyncio.sleep(0)
195-
called2 = True
196181

197182
gc = self.node.create_guard_condition(coroutine)
198183
try:
199184
gc.trigger()
200185
executor.spin_once(timeout_sec=0)
201186
self.assertTrue(called1)
202-
self.assertFalse(called2)
203-
204-
called1 = False
205-
executor.spin_once(timeout_sec=1)
206-
self.assertFalse(called1)
207-
self.assertTrue(called2)
208187
finally:
209188
self.node.destroy_guard_condition(gc)
210189

rclpy/test/test_guard_condition.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,15 +26,19 @@ def setUpClass(cls):
2626
rclpy.init(context=cls.context)
2727
cls.node = rclpy.create_node(
2828
'TestGuardCondition', namespace='/rclpy/test', context=cls.context)
29-
cls.executor = SingleThreadedExecutor(context=cls.context)
30-
cls.executor.add_node(cls.node)
3129

3230
@classmethod
3331
def tearDownClass(cls):
34-
cls.executor.shutdown()
3532
cls.node.destroy_node()
3633
rclpy.shutdown(context=cls.context)
3734

35+
def setUp(self):
36+
self.executor = SingleThreadedExecutor(context=self.context)
37+
self.executor.add_node(self.node)
38+
39+
def tearDown(self):
40+
self.executor.shutdown()
41+
3842
def test_trigger(self):
3943
called = False
4044

0 commit comments

Comments
 (0)