段階公開を 5% で開始した翌朝、Play Console の Vitals に ANR がひとつ増えていました。
個人開発でアプリを出していると、この 5% の窓はとても短く感じます。24 時間ほど様子を見て、続けるか止めるかを決めなければなりません。クラッシュであれば Crashlytics のスタックトレースを読めば当たりが付きます。けれど ANR は事情が違いました。
開いたダンプには、96 個のスレッドが並んでいました。
5% の窓では、読む時間そのものが足りません
私は普段、段階公開を 5% → 25% → 50% → 100% と刻んで、Crash-free users と ANR 率を見ながら次に進めるかを決めています。クラッシュ側については、難読化を戻してから Gemini に渡す前処理を組んでいて、これは今もうまく回っています(この経緯は難読化されたスタックトレースを Gemini に渡していた記録 に書きました)。
同じ調子で ANR ダンプも Gemini に丸ごと投げてみたのですが、これがうまくいきませんでした。返ってくる説明はどれも「メインスレッドが I/O を待っている可能性があります」といった、ダンプを見なくても言えることばかりだったのです。
原因は、渡し方にありました。
ANR ダンプはスタックトレースではなく、スレッドの一覧です
クラッシュのスタックトレースは、落ちた一本の呼び出し履歴です。読む対象が最初から一つに絞られています。
ANR ダンプは違います。ANR は「メインスレッドが一定時間応答しなかった」という事象なので、システムはプロセス内の全スレッドの状態を書き出します。広告 SDK・画像ローダ・Firebase・コルーチンのディスパッチャが載っている実アプリでは、これが数十から百を超える単位になります。
そして重要なのは、その中にスレッド同士の関係が書かれている ことです。
"main" prio=5 tid=1 Blocked
- waiting to lock <0x0e3a41c8> (a net.dolice.wallpapers.data.AssetIndex) held by thread 27
at net.dolice.wallpapers.data.AssetIndex.lookup(AssetIndex.java:184)
at net.dolice.wallpapers.ui.GridAdapter.onBindViewHolder(GridAdapter.java:96)
waiting to lock <アドレス> ... held by thread 27 の一行が、main と 27 番スレッドを結ぶ辺です。そして 27 番自身が別のロックを待っていれば、辺はさらに先へ伸びます。
ANR の原因は、この連鎖の終端 にいます。main のフレームには「待っている」という事実しか書かれていません。
main だけを抜くと、待たせていた側が消えます
まず、削り方を三通り用意して比べました。検証には、Android の ANR トレース形式に沿って組み立てた 96 スレッドのダンプを使っています(実機の ANR そのものではなく、形式を再現した検証用データです)。
方式 スレッド数 文字数 行数 削減率
① 全文をそのまま渡す 96 33,616 686 0%
② main スレッドのみ 1 681 10 98%
③ 待ちの連鎖のみ 3 1,898 30 95%
②と③の差は 1,217 文字、全体の 3.6% です。ほとんど誤差のような差でした。
ところが、この 3.6% の中に自社パッケージのフレームがこれだけ入っていました。
方式 残る自社フレーム
② main のみ AssetIndex.lookup / GridAdapter.onBindViewHolder
③ 待ちの連鎖 上記に加えて AssetIndex.rebuild / CatalogSync.run / ThumbStore.warm
②だけを見ると、AssetIndex.lookup が遅いように読めます。実際に待たせていたのは 2 ホップ先の ThumbStore.warm — ディスクキャッシュからサムネイルを読み込んでいたスレッドでした。②の情報だけを渡せば、Gemini は lookup の中身を疑う筋の良さそうな説明を書いてくれます。そしてそれは外れています。
トークンを 98% 削るか 95% 削るかは、費用の話ではほぼ同じです。原因が残るかどうかの話では、まったく同じではありませんでした。
待ちの辺だけを取り出す
ダンプをスレッド単位に切り、ロックの待ちと保持だけを抜き出します。ANR ダンプは空行 2 つでスレッドが区切られているので、分割自体は素直に書けます。
import re
BLOCK_HEAD = re.compile( r ' ^ " ( ?P<name> . * ) " . * \b tid= ( ?P<tid> \d + )\s + ( ?P<state> \w + ) ' )
WAIT_LOCK = re.compile( r '- waiting to lock < ( ?P<addr> 0x [ 0-9a-f ] + ) > . * held by thread ( ?P<owner> \d + ) ' )
HELD_LOCK = re.compile( r '- locked < ( ?P<addr> 0x [ 0-9a-f ] + ) >' )
def parse (text):
"""ANR ダンプをスレッド単位に切り、待ち → 保持の辺を取り出す"""
threads, order = {}, []
for raw in text.split( " \n\n " ):
m = BLOCK_HEAD .match(raw.strip())
if not m:
# ヘッダ行や末尾のフッタはここで落ちる
continue
tid = int (m.group( "tid" ))
w = WAIT_LOCK .search(raw)
threads[tid] = {
"tid" : tid,
"name" : m.group( "name" ),
"state" : m.group( "state" ),
"raw" : raw.strip(),
# 待っている相手の tid。待っていなければ None(=連鎖の終端候補)
"waits_for" : int (w.group( "owner" )) if w else None ,
"holds" : HELD_LOCK .findall(raw),
}
order.append(tid)
return threads, order
waits_for が None のスレッドが連鎖の終端です。ここが Native 状態でファイル I/O やネットワークを叩いていれば、それが ANR の実体になります。
held by thread を持たない待ち方(Object.wait() による条件待ちなど)はこの正規表現では辺になりません。それは意図した挙動です。条件待ちのスレッドは、待っている相手がダンプ上に明示されないため、機械的に辿れる情報がそもそも存在しないからです。
連鎖をたどり、循環で止める
辺が取れたら、main から順に辿ります。ここで気をつけたのは、デッドロックでは連鎖が循環する という点です。素朴に while で回すと戻ってこなくなります。
def lock_closure (threads, root = 1 , max_hops = 8 ):
"""main から待ちの辺を辿って到達できるスレッドだけを集める。
循環(デッドロック)でも止まるよう、訪問済みで打ち切る。"""
seen, chain, cur, hops = set (), [], root, 0
while cur is not None and cur not in seen and hops < max_hops:
seen.add(cur)
chain.append(cur)
cur = threads.get(cur, {}).get( "waits_for" )
hops += 1
# ループを抜けた時点で cur が訪問済みなら、それは循環している
deadlock = cur is not None and cur in seen
return chain, deadlock
def render (threads, tids):
return " \n\n " .join(threads[t][ "raw" ] for t in tids if t in threads)
訪問済み集合と max_hops の二重で止めているのは、保険を重ねたかったからではありません。訪問済みだけだと循環は止まりますが、実機のダンプで tid の参照が壊れている(存在しないスレッドを指している)ケースに threads.get() が None を返して静かに終わります。逆に max_hops だけだと、循環の検出そのものができません。両方あって初めて「止まった理由」を戻り値で区別できます。
この deadlock フラグは、後で Gemini に渡すときの前提として効いてきます。循環しているなら「どちらのスレッドが悪いか」という問いは成立せず、「どの順序でロックを取っているか」を問うべきだからです。
実際に、27 番が main のロックを待つ形へ書き換えたダンプを通したところ、循環検出: True を返し、連鎖は 3 スレッドで打ち切られました。
削った先に残っていたもの
三方式を実際に走らせて出力を並べたときが、この前処理を書いていていちばん腑に落ちた瞬間でした。
① 全文 threads= 96 chars= 33616 lines= 686 削減率= 0%
② main のみ threads= 1 chars= 681 lines= 10 削減率= 98%
③ ロック連鎖のみ threads= 3 chars= 1898 lines= 30 削減率= 95%
main からの待ち連鎖: 1(main) -> 27(pool-3-thread-2) -> 41(glide-disk-cache-thread-0)
循環検出: False
前処理を書き始めたとき、私が最初に実装したのは②でした。ANR は「メインスレッドが止まった」事象なのだから、メインスレッドを渡せばよい。そう考えるのが自然だと思ったのです。
実際には逆でした。ANR は「メインスレッドが他の何か を待った」事象なので、メインスレッドの中には答えがありません。答えは常に、自分ではないスレッドの側にあります。
削減率の数字も、予想と食い違いました。私は「連鎖を残すと大きく膨らむだろう」と身構えていたのですが、96 スレッドのうち意味を持つのは 3 つだけで、残りは待機中のワーカーとデーモンでした。全文の 95% は、ほぼ定型文だったということになります。
スレッド数が膨らむこと自体は、私のような個人開発の構成だと避けにくいところがあります。AdMob のメディエーションにアドネットワークを何社か足し、画像ローダを使い、計測 SDK を入れていけば、自分で書いた覚えのないスレッドが静かに増えていきます。それらを一つずつ理解しようとするのではなく、main から辿れるかどうかという一本の線で切ってしまうほうが、限られた時間の使い方としては現実的でした。
Gemini 側には、連鎖の形を前提にして聞く
削ったダンプを渡すときは、こちらが何を渡したのかを明示しています。連鎖の順序という前提を共有しないまま 3 スレッド分のテキストを渡すと、3 つを並列の候補として扱われてしまうためです。
from google import genai
from google.genai import types
client = genai.Client( api_key = "YOUR_API_KEY" )
PROMPT = """あなたは Android の ANR を診断します。
渡されるのは ANR ダンプ全文ではなく、main スレッドからロックの待ち関係を辿って
到達したスレッドだけを、待ち順に並べたものです。
- 先頭が main、最後が連鎖の終端です
- 終端のスレッドが実際にブロックしていた処理を保持しています
- deadlock が true の場合、順序ではなくロック取得順の設計を問題にしてください
出力は次の 3 点に絞ってください。
1. 終端で行われていた処理
2. main がそれを待つことになった経路
3. main から外すべき処理の具体的な候補
"""
def diagnose (chain_text: str , deadlock: bool ) -> str :
resp = client.models.generate_content(
model = "gemini-3.7-flash" ,
contents = [
types.Part.from_text( text = PROMPT ),
types.Part.from_text( text = f "deadlock: { deadlock } " ),
types.Part.from_text( text = chain_text),
],
config = types.GenerateContentConfig(
# 診断は毎回同じ答えに寄せたいので thinking は控えめに
thinking_config = types.ThinkingConfig( thinking_level = "low" ),
max_output_tokens = 1024 ,
),
)
return resp.text
thinking_level を low にしているのは、渡す情報を先に絞り込んでいるからです。前処理で 3 スレッドまで落としてあるなら、モデル側に探索させる余地はほとんど残っていません。探索させるべき対象を自分のコードで特定できているとき、思考の予算は素直に削れます。
なお temperature などのサンプリングパラメータは非推奨になりました。まだ動きますが、明示していた箇所は外したときの差分を早めに見ておくほうが安全です(この移行についてはサンプリングパラメータの非推奨と多様性の担保 で扱っています)。
段階公開の窓に、判断を間に合わせる
この前処理を組んでから、ANR が出たときの手順はこうなりました。
Play Console から ANR クラスタのダンプをダウンロードする
parse と lock_closure を通し、連鎖と deadlock フラグを得る
連鎖が 1 スレッドだけ(= main が誰も待っていない)なら、ロック競合ではないので別の筋で見る
2 スレッド以上なら、そのまま Gemini に渡して終端の処理を言語化してもらう
終端が自社パッケージなら修正へ、SDK 側なら初期化タイミングの見直しへ
3 番目の分岐を入れているのは、ロック待ちではない ANR — 単純にメインスレッドで重い処理をしている場合 — が実際にあるからです。この場合は連鎖が伸びないので、前処理の戻り値がそのまま切り分けになります。判定のコストはゼロで、len(chain) を見るだけです。
段階公開の 5% の窓で本当に足りないのは、計算資源ではなく読む時間でした。33,616 文字を読む時間はありませんが、1,898 文字なら朝のうちに目を通せます。前処理はモデルのためというより、自分のために書いたのだと今は思っています。
まずは手元の ANR ダンプを一つ、parse に通して chain の長さだけ見てみてください。それが 1 なのか 3 なのかで、次に読むべき場所が変わります。
読んでいただき、ありがとうございました。私自身まだ ANR とは付き合い方を探っている途中ですので、より良い削り方を見つけたら書き足していきます。