サーバに問い合わせて時間差で答えが来る非同期な世界での状態遷移(Python)

 ここまでソケット通信でサーバとやり取りする所を色々と作って来ました。前回でサーバに問い合わせてサーバから結果を返してもらうコールバックの仕組みを実験出来ました:

 クライアント側は、

sendCommand( client, sv, cmd, callback )

こういう関数呼び出しによって少し時間が経過した後サーバーからの返答をcallback関数で受ける事が出来ます。一方でsendCommand関数自体はブロッキングせず即時に返って来ます。このように呼び出し関数自体は即時に戻ってきて、欲しい答えは時間差でやって来るような構造を「非同期処理」と言います。

 コマンドが単発的な物であれば非同期処理でもそれ程問題ありません。例えば機器の現在位置を知りたくて"getPosition"を送信し、callbackに返ってくる位置座標を表示する。これはcallback内で「showPosition」みたいな表示関数に座標を投げれば良いだけです。しかし、その位置座標を見て「じゃあもう少し移動しようか」とか「次は回転」みたいな連続的な処理、すなわち状態遷移を行わせたいとなると、あれれどうやって実装したら良いんだ?となるかもしれません。

 非同期の世界で状態遷移をしなくてはならなくなった場合、どういう実装をする事になるのでしょうか?今回はそんな話題です。

ロボットに位置を問い合わせて位置調整する

 非同期状態遷移をイメージしやすいように具体的な例で考えて行こうと思います。Raspberry Piを積んだロボットがいるとしましょう。ロボットは自分の位置を測定出来て、クライアント側から「いまどこ?(getPosition)」とネットワーク越しに尋ねるとその位置を返してくれます(getPositionのコールバック)。ロボットは移動する事も出来て「動け(move)」と命令するとその位置に向かって動き出します。もちろんロボットは物理的な機械なので、ゲームのキャラのように瞬時にその位置に移動できるわけではありません。モーターを回して車輪を回転させて指定の位置へ時間をかけて移動します。しばらく動き続け、指定の位置へ到達したら「到着したよ」と返答してくれます(moveのコールバック)。

 クライアント側は最初にロボットの位置を聞き、目的地に移動するように命令し、目的地に到達したらロボットに「行動せよ(action)」と命令します。これでロボットはその到着位置ですべき事を始めてくれます。

 つまりクライアントは、

  • getPosition → 返答位置が目的座標じゃない → move

  • moveのコールバックを待つ

  • move終了のコールバックを受けたらaction命令(一連の命令を終了)

こういう状態遷移を管理する事になります。

状態遷移図を描いてみよう

 同期にせよ非同期にせよ、状態遷移がある場面は状態遷移図を描く事をお勧めします。上のロボットの一連の動作についてクライアント側の状態遷移は以下のようになります:

 最初に位置を問い合わせた時に、すでに目的地に達している事もありますので「位置問い合わせ」フェーズからは2つに分かれます。簡単のためエラー遷移は省いています。

 このように状態遷移を書くと、各状態が「待機→結果判断→次の状態へ」というひとまとまりになっている事に気付けると思います。これに沿って実際にクライアントコードを考えてみましょう。

"getCommand"コマンド周りで感覚を掴もう

 非同期の状態遷移は大概イベント駆動(イベントドリブン)になります。イベント駆動というのはプログラム上で何かイベントが起こった時に、それに対応する関数が呼ばれるようにする構造の事です。GUIのプログラミングなどは普通イベント駆動で実装します。

 それを少し意識しつつ試しに位置問い合わせの状態を仮想的にPythonの関数で書くとこうなります:

# 位置問い合わせ状態
def stateGetPosition():
   # サーバに位置問い合わせ開始
   sendCommand( "getPosition", resultGetPosition )
 
# 位置問い合わせ結果
def resultGetPosition( res ):
   # 結果文字列を座標値へ変換
   x, y = convToVector2( res )
 
   # 位置≠目的地なら移動指示へ
   if x != targetX || y != targetY:
      stateMove( x, y )
   else:
      stateAction()

 stateGetPosition関数はサーバへ位置を問い合わせするエントリ関数です。メインスレッドで呼ぶのはこの関数になります。sendCommandの第1引数でコマンドを、第2引数で結果を受けるコールバック関数を渡します。sendCommandは内部でスレッドを起動しサーバへ"getPosition"コマンドを送出、その返答をスレッド内で待ち、返答をresultGetPosition関数に返します。

 stateGetPostion関数はブロッキングする事無く抜けますので、呼び出し元(メインスレッドなど)はループするなりして適当に待ってます。するとサーバから返答が戻って来てresultGetPosition関数が呼ばれます。引数のresに結果(座標)が文字列で返ってくるので、それを座標のベクトル(x,y)に変換します。もし(x,y)が目的地からずれていたら、目的位置に移動する命令フェーズ(stateMove)に遷移します。目的地だったらstateActionへ移行する事になります。ここで状態の遷移が発生しているわけです。

 メイン側で待っているとresultGetPosition関数がポンと呼ばれる。この辺りがイベント駆動っぽい動作ですね。非同期の状態遷移のノリは大抵はこんな感じです。一つの状態を2つの関数(コマンド出力、結果)で記述すれば良いだけなので分かり良いです。

 ちなみにC++やC#などの場合は、

// C#版の非同期状態遷移

void stateGetPosition() {
   sendCommand( "getPosition", res => {
      var pos = convToVector2( res );
      if ( pos != targetPos )
         stateMove( pos )
      else
         stateAction()
   } );
}

 このように命令と結果を一つの関数の中で記述出来ます。sendCommandの第2引数に「ラムダ式(無名関数)」を指定できるため、結果関数を定義する必要が無く、よりスッキリ非同期の状態遷移を書けます。Pythonにもラムダ式(lambda)はあるのですが、今の所かなり使いづらい印象でして、上のようにはちょっと書けません。そのためresultGetPosition関数のように別の応答関数を定義した方が記述の面でより良いかなと思います。

 この後のノリは基本一緒です。stateMove関数も内部で"move"コマンドをsendCommand関数に渡して、resultMove関数で結果を受けます。これを繋げて行けば状態遷移図を再現できるというわけです。

 メインにより優しくしたい場合は一連の動作が終わった後に「全部終わったよ」というコールバックを呼ぶようにしてあげても良いですね。

終わりに

 今回はクライアント-サーバの通信で考慮する事になる非同期での状態遷移の実装方法の一つを見て来ました。実装方法は必ずしもこれだけでは無いですが、どの場合でも大概はイベント駆動的な構造になります。非同期コードに王道はありませんので、場合に合わせて柔軟に対処しましょう。

 さて、ここまででソケット通信をある程度がりっと触れましたので、次回はソケット通信の一つの制御機構である「selector」について検討して行こうと思います。

ではまた(^-^)/

この記事が気に入ったらサポートをしてみませんか?