AI時代のエンジニア 判断力の鍛え方 2

AIの書いた非同期処理は、なぜ実機で遅いのか

第2回の今回は、AIが書いた非同期処理が実機で遅くなる原因を地図SDK開発の実例から掘り下げ、イベント間引きや寿命管理などエンジニアが見るべき観点について解説します。

勝又 雅史

9月9日 6:30

はじめに

こんにちは。AIを用いた開発効率がよすぎて最近寝不足の日々が続いている著者です。気付くと3:30amくらいになって、エージェントを走らせておいて自分は布団に入り、7:00amくらいに目が覚めて仕事に行く。ヤバいです。これは中毒症状かもしれません。

それくらい、AIを使った開発は面白い。アイデアを形にすることが好きなエンジニアにとって、今はかなり危険な時代です。

しかし、だからと言ってAIにコーディングを全て任せられるかと言うと、そうではないですよね。特にモバイルアプリ開発の世界ではなおさらです。なぜなら事件はコードの上で起きてるのではないからです。実機の上で起きているからです。(ネタが古いですね)。

前回の最後に予告したとおり、今回は「非同期処理」の話をします。

AIは、非同期処理のコードをかなり正確に書けるようになりました。async/awaitやコルーチンを使った1回分の処理だけを見れば、きれいなコードが出てきます。それでも実機に載せると遅い。カクつく。ときどき、画面を閉じたあとにクラッシュする。

なぜそうなるのか、そしてエンジニアは何をレビューすべきなのか。私が個人プロジェクトで実際に踏んだ落とし穴を題材に、具体的に書いていきます。

複数の地図SDKを同じAPIで扱うライブラリ

私は個人プロジェクトとして、複数の地図SDKを同じAPIで扱うためのライブラリをAIと一緒に開発しています。地図SDKとは、Google MapsやMapLibreのような、アプリに地図を組み込むためのSDKのことです。SDKごとにAPIがまったく違うので、それを吸収する共通層を作っています。

出発点は素朴でした。まず複数の地図SDKを、それぞれ「とりあえず表示する」ところから始める。表示できたら、マーカーを立てる処理を各SDKで書いてみる。すると似たコードが並ぶので、共通化できる部分を括り出す。共通部分が増えてきたら、DI(依存性注入)を使ってSDKごとの実装を差し替えられるようにして少しずつ抽象度を上げていく。この過程は、最初からAIと一緒にやってきました。

AIが提案した抽象化と最終的な抽象化は違った

抽象化を進める過程で印象に残っているのは、AIが提案してきた設計と、最終的に私が採用した設計が、かなり違ったことです。

AIの提案は、教科書的には正しいものでした。共通のインターフェースを定義し、SDKごとにそれを実装するアダプタを書く。アプリが addMarker() を呼べば、アダプタが各SDKの流儀に翻訳してそのまま呼び出す。いわば「命令の翻訳機」です。デザインパターンの本に載っている形ですし、実際、最初はこれで動きました。

しかし最終形は違うものになりました。いまの設計では、アプリ(UI層)は命令を発行しません。マーカーの位置や色を持つ状態オブジェクトを書き換えるだけです。共通層(core層)がその状態の変化を監視して差分を計算し、SDKごとの実装(ドライバー層)に「変わった分だけ」を配ります。ドライバーは起動時に「自分ができること」をレジストリに登録し、拡張機能はレジストリ経由で能力を引き当てる。DIはこのレジストリの形に落ち着きました。

翻訳機型から状態と差分の型へ、なぜ作り変えたのか。当時は言語化できていませんでしたが、いま振り返るとはっきりしています。翻訳機型には「呼び出しを捨てる」自由がないからです。呼ばれたら訳して流すしかない。呼び出し1回が、そのまま下の層への伝達1回になる。

これが何を意味するかは、非同期処理の話をすると見えてきます。ここからが本題です。

落とし穴は「1回のコード」ではなく「回数」にある

地図アプリでは、イベントが連続的に発生します。ユーザーが地図を1回ドラッグする間に、カメラ位置変更のイベントは数十回発生します。マーカーをドラッグすれば、位置の更新が毎フレーム、つまり秒間60回発生します。

私のプロジェクトの実測値を挙げます。ある地図SDKは、1秒間のカメラアニメーションの間に388回、カメラ変更のイベントを発火しました。しかも大半は前回とまったく同じ値です。

このイベントを受けるハンドラをAIに書かせると、正しいコードが出てきます。イベントを受け取り、画面の4隅の座標を緯度経度に逆変換して、表示領域を計算し、状態を更新する。1回分のコードとしては何も間違っていない。コンパイルは通り、デモでも動きます。

しかし、画面の4隅を逆変換する処理は安くありません。388回のイベントに対してこれを素直に実行すると、逆変換がメインスレッドで毎秒1,500回以上走りました。結果として何が起きたか。クラッシュではありません。エラーログも出ません。同じ画面に並べていた別の地図のアニメーションが、途中で固まるという形で表面化しました。

数値は私の検証環境で計測した一例であり、SDKや端末、OSによって変わります。重要なのは「コードレビューしても呼び出し回数は分からなかった」という点です。

ここに、この落とし穴の嫌らしさが詰まっています。1回分のコードはどこも間違っていない。壊れるのは「頻度×1回のコスト」という積であって、その積はコードのどこにも書かれていないのです。AIはイベントハンドラの中身を書けます。しかし、そのハンドラが秒間何回呼ばれるかはコードの外側、つまり実機とユーザーの指の中にしかありません。

「重い処理を非同期に逃がす」だけでは解決しない

「重い処理がメインスレッドにあるのが悪い。非同期にしてワーカーに逃がせばよい」と思われたでしょうか。AIに相談しても、まずその答えが返ってきます。そして、これが第二の落とし穴です。

私のライブラリでは、重い処理をUI層でやると描画が止まるので、core層・ドライバー層で非同期に処理する構成にしています。ところが、UI層で連続発生するイベントを非同期境界(コルーチン、チャネル、スレッド切り替え、あるいはWebViewのブリッジ)越しにcore層へ送り込むと、今度は遅延が発生します。

理屈は単純で、供給が処理を上回れば、キューに仕事が溜まるからです。1件あたりの処理が十分速くても、イベントが秒間388回来れば、処理側は常に「少し前のイベント」を消化し続けることになります。症状はやはりクラッシュではありません。地図が指よりワンテンポ遅れてついてくる。マーカーがドラッグに追従しない。ユーザーの言葉では「このアプリ、重い」です。

つまり、同期のままでは詰まり、非同期に逃がすと遅れる。どちらもコードとしては正しく、どちらも製品としては失格です。では、どうするのか。

対策1: 捨てる。ただし「何を捨ててよいか」は仕様の問題

答えの半分は、拍子抜けするほど単純です。途中の値を捨てる。388回のうち大半は同じ値なのだから、同じ値なら配らない。処理が追いついていないなら、溜まった古い要求は捨てて最新の1件だけ処理する。

単純だと書きましたが、この結論に落ち着くまでには1年かかっています。コミットログを遡ると、UI層とcore層の境界に最初の間引きを入れたのは開発の初期で、その3ヶ月後には数千件のマーカーを表示するデモを作ってANR(アプリ無応答)を2度直し、メモリリークを追いかけ、空間検索の一部をC++に移植し……という格闘の記録が、1つのプルリクエストに40個のwipコミットとして残っていました。ちなみに、このとき読み込んでいたデモデータは日本全国の郵便局です。前回で郵便局の例を出したのは、偶然ではありません。

その試行錯誤の末に残った整理が、連続イベントを2種類に分けて扱う、というものでした。

1つ目は「集合が変わる」イベントです。マーカーが追加された、削除された。これは短い静止窓で待ち合わせてまとめて処理する、いわゆるdebounceで問題ありません。ただし件数の弁を付けます。大量に積まれたら窓の期限を待たずに即時処理する。そうしないと一括ロード時に待ちが生じます。

ここでdebounceしているのは「差分そのもの」ではなく、「差分をまとめて送り出すタイミング」です。追加・削除の記録は内部に蓄積しているため、論理的な変更は1件も失いません。追加・削除は差分の記録そのものなので、1件捨てればマーカーが消え残ります。まとめて運ぶが、全部運ぶ。

2つ目は「同じオブジェクトの中身が変わり続ける」イベントです。ドラッグ中のマーカー位置がこれです。こちらは逆に、途中の値を捨てても構いません。最新の位置だけが真実で、3フレーム前の指の位置に用はないからです。そして、ここにdebounceを使うと事故になります。

debounceは「入力が静まるまで窓を延長する」仕組みなので、毎フレーム値が変わり続けるドラッグ中は指が止まるまで一度も配信されないのです。ここで使うべきは「窓ごとに最新値を1回だけ配る」方式です。ライブラリによってsample、throttleLatest、conflateなど名称や細かな挙動は異なります。重要なのは、値は間引かれるが、流れは止まらない、ということ。

まとめると、集合の変化は「捨てずに、まとめて運ぶ」(debounce+バッチ)、中身の変化は「捨てて、最新だけ運ぶ」(sample)。窓の形の違いに見えて、実際に設計を分けているのはそのイベントを捨ててよいかという1点です。

似た道具は他にもあります。容量1のチャネルにして、処理が追いつかない間に来た要求は古い方から捨てる。処理中フラグを立てて、処理中に来た中間値はスキップする。どれも本質は同じで「最新の1件だけが真実」という性質を利用しています。

ただし、注意が要ります。捨ててはいけないイベントが必ず混ざっているのです。ドラッグ中の中間位置は捨ててよくても、指を離した瞬間の確定位置を捨てるとマーカーがずれた場所に残ります。移動中のカメラ通知は間引いてよくても、移動終了の通知を落としたら表示領域の再計算が永遠に走りません。私のコードでは、間引き関数に「強制フラグ」を1つ持たせ、確定系のイベントだけは必ず通す形にしています。

お気づきでしょうか。「何を捨ててよいか」は、非同期処理の知識ではなく、仕様の判断です。debounceかsampleか、という選択はライブラリの使い方の問題に見えて、実際には「ユーザーの指が動いている間、画面はどうあるべきか」という製品の話をしています。AIはどちらの実装も一瞬で書けます。どちらを選ぶべきかはコードの外側にあります。

対策2: あえて同期に残す

答えのもう半分は、逆説的です。非同期処理の設計で重要なのは「何を非同期にするか」ではなく、「ユーザーが待てない処理は何か」を決めることです。

マーカーのドラッグを例にします。指に追従する部分、つまり「タッチ座標を緯度経度に変換し、状態を書き換え、描画位置を更新する」までは、私の実装ではタッチイベントハンドラの中で同期的に一直線に実行しています。非同期境界はゼロです。1フレームの遅れもユーザーに見えるからです。

そして、その状態書き換えから波及する重い処理──他のオーバーレイへの通知、クラスタリングの再計算、表示範囲の再評価──だけを、sampleで間引きながら非同期側へ流します。

つまり、設計の実体はこうです。指に見える速い同期の道と、間引いてよい遅い非同期の道を分け、どの処理をどちらに載せるかを決める。この線引きこそが設計であって、「非同期にする/しない」の二択ではありません。

冒頭の抽象化の話は、ここにつながります。翻訳機型の抽象化を捨てて状態と差分の型にしたのは、この線引きをするためでした。UI層が状態を書き換えるのは同期で即座。その変化をどの粒度でどの層に配るかは、core層が間引きの意味論(debounceかsampleか、弁は何件か、確定イベントはどれか)ごと管理する。捨てる自由は、抽象化の段階で作り込んでおかないと、後から個々のハンドラに散らばった呼び出しを間引いて回ることになります。AIの提案した抽象化が「間違っていた」のではありません。あの形には、この問題がまだ写っていなかったのです。

対策3: 寿命を決めて、確実に殺す

連続イベントの間引きとセットで必ず問題になるのが、前回も少し触れた「画面破棄後にレスポンスが返ってくる」問題です。非同期に逃がした仕事は、画面より長生きし得ます。

教科書的な答えは「画面のライフサイクルに紐づいたスコープで起動し、破棄時にキャンセルする」で、AIもそう書いてくれます。しかし実際に踏んだ穴は、その一段深いところにありました。実例を2つ挙げます。

1つ目。破棄時にcancel()を呼んでいたのに、二重描画が起きました。原因は、キャンセルした時点でコルーチンが待機を抜けて処理の途中まで進んでいたことです。こちらが後始末を終えた後にあちらが途中から再開して、消したはずの描画をもう一度載せる。修正はcancel()をcancelAndJoin()に変えること、つまり「キャンセルを要求する」ではなく「終わるのを待ってから次へ進む」でした。1語の違いですが、この違いはレースを実機で観察しないとまず気づけません。

2つ目。共通層のdestroy()でワーカー系のスコープを止めていたのに、あるSDKだけ破棄後にクラッシュしました。メインスレッド側のスコープに積まれていたカメラ操作が、地図オブジェクトの破棄後に実行されて「インスタンスは既に閉じられています」と例外を投げたのです。対策は、そのドライバーの破棄処理でメイン側スコープも止め、さらにawaitをまたぐ長い処理の各段に「破棄済みなら抜ける」というガードを置くことでした。awaitの直後というのは、「その間に世界が変わったかもしれない地点」です。JavaScript側の実装でも同じ形のガード(awaitの前後でcancelledを二度確認する)を入れています。

まとめると、非同期処理の寿命管理でレビューすべきなのは「キャンセルしているか」ではありません。「キャンセルが完了する前に、次の処理が始まらないか」「awaitから戻ってきた世界で、前提はまだ生きているか」です。

エンジニアは何をレビューするのか

ここまでの話を、レビューの観点として並べ直します。AIが書いた非同期コードを前にしたとき、私が確認するのは次の5つです。

  1. このハンドラは1秒に何回呼ばれるか。そして1回のコストとの積はいくらか。積はコードに書かれていないので、イベント源のドキュメントと実測で確かめるしかありません。 
  2. 非同期境界を何回越えるか。スレッド切り替え、チャネル、ブリッジ。境界はそれぞれがキューであり、キューは遅延の在庫です。 
  3. 途中の値を捨ててよいか。捨ててよいなら、捨てる実装になっているか。debounceとsampleの取り違えは、コンパイラもテストも検出しません。そして「捨ててはいけない確定イベント」が保護されているか。 
  4. 速い道はどこか。 ユーザーの指に直結する経路が、律儀に非同期の行列に並んでいないか。 
  5. この処理の寿命は何と一緒か。画面が死んだら一緒に死ぬか。キャンセルは「要求」で終わっていないか。awaitの後に生存確認はあるか。

どの項目も、直し方はAIに聞けば出てきます。実際、私のプロジェクトの間引き処理やキャンセル処理の多くは、方針を決めたあとの実装はAIが書きました。しかし、この5つの問いを立てること自体は、AIからは出てきませんでした。問いが生まれる場所は、コードではないからです。

実機で指を動かして「ワンテンポ遅れたな」と感じる。並べた2つの画面の片方が固まるのを見る。ログに破棄後のスタックトレースを見つける。そこから遡って初めて、コードが容疑者になります。

前回、エンジニアの仕事は「コードの外側を見て判断すること」だと書きました。非同期処理は、その最たる領域です。正しさがコードの中に書けない──頻度と、端末性能と、ユーザーの指と、画面の寿命という「外側」との掛け算でしか決まらない──からこそ、AIが完璧な構文で書いた非同期コードが実機の上で静かに破綻します。

破綻の形がクラッシュなら、まだ楽です。多くの場合、それはただの「なんか重いアプリ」として、レビューをすり抜けてストアの星2つに変わります。それを防ぐ最後の関門は、いまのところ実機を触る人間の指です。

おわりに

次回は「UI再描画の落とし穴」をテーマに解説します。宣言的UIで状態管理を誤ると何が起きるのか──今回の「状態を書き換えるだけ」の設計が、UIフレームワークの再描画とぶつかる話です。

人気記事トップ10

人気記事ランキングをもっと見る

企画広告も役立つ情報バッチリ! Sponsored