はじめに
ROS 2のコールバック関数内で、別ノードのROSサービスを呼び出す方法を紹介する。
ROS 1と違いROS 2では、SingleThreadedExecutorやデフォルトのコールバックグループ構成では、コールバック関数内で同期的にサービス結果を待つとデッドロックが発生することがある。
本記事では、MultiThreadExecutorとコールバックグループの設定を行うことで、コールバック関数内から別ノードのサービスの同期呼び出し方法を紹介する。
動作確認環境
- Ubuntu 22.04 x86_64/arm64
- ROS 2 Humble
デフォルト設定でデッドロックする原因
Subscriberやタイマーなどのコールバック関数内で別ノードのサービスを呼び出す場合、コールバックグループを設定しないデフォルト構成や、SingleThreadedExecutorを利用している場合は、サービスコール時にデッドロックが発生し、サービスの結果を取得できない。
図示すると以下のようになる。
Subscriber callback
│
▼
async_send_request()
│
▼
wait_for()
│
▼
Executorのスレッドを占有
│
▼
Service response callback(サービス応答コールバック)を実行したい
│
▼
実行できない(デッドロック)
これを回避するには、MultiThreadExecutorとコールバックグループを適切に設定する必要がある。詳細は以下の方法で説明する。
方法
ノードのExecutorには、MultiThreadExecutorを使い、サービス呼び出しを入れたいコールバックの、コールバックグループに「Reentrant」を指定する。もしくは、サービス呼び出しとコールバックグループ「MutuallyExclusive」にして、別々のコールバックグループを作成する。
- Reentrant
- 同じコールバックグループに属する複数のコールバックが同時実行されることを許可する
- MutuallyExclusive
- 同じコールバックグループに属するコールバックは同時実行されない(排他処理)
Executorの詳細については、こちらの公式ドキュメントを参照。また、コールバックグループの詳細については、こちらの公式ドキュメントを参照。
ここで、MultiThreadExecutorとコールバックグループの役割を簡単に説明する。MultiThreadExecutorを使うことにより、一つのプロセス上で動作するノードのコールバックを複数スレッドで実行できるようになる。複数スレッドに対して、どのようにコールバックを割り当てるかがコールバックグループの設定となる。つまり、コールバックグループの設定により、各コールバックを同期的に動作させたり、非同期に動作させたり制御が可能となる。

(公式ドキュメントの図を引用)
サンプルコード
C++、Pythonそれぞれのサンプルコードを示す。
本サンプルでは、サービスクライアントとサービス呼び出しを行うSubscriberを同じReentrantコールバックグループへ所属させている。MutuallyExclusiveを利用する場合は、Subscriberとサービスクライアントをそれぞれ別のMutuallyExclusiveコールバックグループへ割り当てることで、同様の構成を実現できる。
C++の場合
ポイント
- MultiThreadExecutorを利用する
- CallbackGroupに
Reentrantを指定する -
async_send_request()で非同期にサービスを送信し、future.wait_for()で結果が返るまで同期的に待機する
#include <chrono>
#include "rclcpp/rclcpp.hpp"
#include "std_msgs/msg/string.hpp"
#include "std_srvs/srv/trigger.hpp"
class SampleNode : public rclcpp::Node
{
public:
SampleNode()
: Node("sample_node")
{
callback_group_ =
create_callback_group(rclcpp::CallbackGroupType::Reentrant);
trigger_client_ =
create_client<std_srvs::srv::Trigger>(
"/trigger",
rmw_qos_profile_services_default,
callback_group_);
rclcpp::SubscriptionOptions options;
options.callback_group = callback_group_;
subscription_ =
create_subscription<std_msgs::msg::String>(
"/topic",
10,
std::bind(&SampleNode::callback, this, std::placeholders::_1),
options);
}
private:
void callback(const std_msgs::msg::String::SharedPtr msg)
{
auto request =
std::make_shared<std_srvs::srv::Trigger::Request>();
auto future = trigger_client_->async_send_request(request);
if (future.wait_for(std::chrono::seconds(3))
!= std::future_status::ready)
{
RCLCPP_ERROR(get_logger(), "Service call timeout");
return;
}
try {
auto response = future.get();
if (!response->success) {
RCLCPP_ERROR(
get_logger(),
"Service failed: %s",
response->message.c_str());
return;
}
RCLCPP_INFO(get_logger(), "Service call succeeded");
}
catch (const std::exception &e) {
RCLCPP_ERROR(
get_logger(),
"Service exception: %s",
e.what());
}
}
rclcpp::CallbackGroup::SharedPtr callback_group_;
rclcpp::Client<std_srvs::srv::Trigger>::SharedPtr trigger_client_;
rclcpp::Subscription<std_msgs::msg::String>::SharedPtr subscription_;
};
int main(int argc, char **argv)
{
rclcpp::init(argc, argv);
auto node = std::make_shared<SampleNode>();
rclcpp::executors::MultiThreadedExecutor executor;
executor.add_node(node);
executor.spin();
rclcpp::shutdown();
return 0;
}
Pythonの場合
ポイント
- MultiThreadExecutorを利用する
- CallbackGroupに
ReentrantCallbackGroupを指定する -
call_async()で非同期にサービスを送信し、Futureの完了を待って同期的に結果を取得する- 10msごとに
Futureの完了を確認している
- 10msごとに
import time
import rclpy
from rclpy.callback_groups import ReentrantCallbackGroup
from rclpy.executors import MultiThreadedExecutor
from rclpy.node import Node
from std_msgs.msg import String
from std_srvs.srv import Trigger
class SampleNode(Node):
def __init__(self):
super().__init__('sample_node')
self._callback_group = ReentrantCallbackGroup()
self._client = self.create_client(
Trigger,
'/trigger',
callback_group=self._callback_group)
self._subscription = self.create_subscription(
String,
'/topic',
self.callback,
10,
callback_group=self._callback_group)
def callback(self, msg):
future = self._client.call_async(Trigger.Request())
deadline = time.monotonic() + 3.0
while rclpy.ok():
if future.done():
break
if time.monotonic() >= deadline:
self.get_logger().error('Service call timeout')
return
time.sleep(0.01)
try:
response = future.result()
except Exception as e:
self.get_logger().error(f'Service exception: {e}')
return
if not response.success:
self.get_logger().error(
f'Service failed: {response.message}')
return
self.get_logger().info('Service call succeeded')
def main(args=None):
rclpy.init(args=args)
node = SampleNode()
executor = MultiThreadedExecutor()
executor.add_node(node)
try:
executor.spin()
finally:
executor.shutdown()
node.destroy_node()
rclpy.shutdown()
if __name__ == '__main__':
main()
注意事項
spin_until_future_completeを使うとうまくいかない。
spin_until_future_complete()は内部でノードをExecutorへ追加する。既に別ExecutorでspinされているノードをグローバルExecutorへ追加しようとするため問題が発生する。そのため、本記事のように既にMultiThreadExecutorでspinしているノードでは利用できない。
def spin_until_future_complete(
node: 'Node',
future: Future,
executor: 'Executor' = None,
timeout_sec: float = None
) -> None:
"""
Execute work until the future is complete.
Callbacks and other work will be executed by the provided executor until ``future.done()``
returns ``True`` or the context associated with the executor is shutdown.
:param node: A node to add to the executor to check for work.
:param future: The future object to wait on.
:param executor: The executor to use, or the global executor if ``None``.
:param timeout_sec: Seconds to wait. Block until the future is complete
if ``None`` or negative. Don't wait if 0.
"""
executor = get_global_executor() if executor is None else executor
try:
executor.add_node(node)
executor.spin_until_future_complete(future, timeout_sec)
finally:
executor.remove_node(node)
まとめ
本記事では、MultiThreadExecutorとコールバックグループの設定を行うことで、コールバック関数内から別ノードのサービスの同期呼び出し方法を紹介した。本記事で紹介した方法は、ROS 1からの移植や同期処理が必要な場面で有効である。
なお、本記事ではROS 1との互換性を考慮して同期呼び出しを紹介したが、新規開発ではサービス完了後の処理をFutureのコールバックで実装するなど、非同期設計を採用する方法も検討するとよい。
参考