2 ポイント 投稿者 GN⁺ 2025-03-01 | 1件のコメント | WhatsAppで共有
  • Java 17アプリケーションで32個のコアがすべて飽和し、CPU使用率が3,200%まで急上昇。thread dumpをCPU time順に並べると、複数のスレッドがTreeMap.put()に張り付いていることが分かった
  • 最初に疑ったループのコードは非効率ではあったが、O(N lg(M))の計算量と入力サイズの検証だけでは、障害レベルの実行時間を説明できなかった
  • 実際の原因は、複数スレッドが共有TreeMapを保護なしで変更したことで、red-black tree内部にcycleが生じ、探索・挿入が無限ループに陥っていたことだった
  • 単純な再現コード、ExecutorService、gRPCサービスでも同じ現象が発生し、スレッドプールはNPEを標準出力に出さないため、原因特定をさらに難しくした
  • 修正はCollections.synchronizedMapConcurrentHashMapへの切り替えだけでは終わらず、executorの例外処理、CPU・NPEアラート、静的解析、マルチスレッドテストも併せて備える必要がある

障害の症状と最初の手がかり

  • マシンはほとんどssh接続すら難しいほど壊れ、CPU使用率は**3,200%**まで上昇した
    • ホストの32個のコアがすべて使用されている状態だった
    • 以前に1個のコアだけが100%使用されていたバグとは対照的な状況だった
  • Java 17ランタイムのthread dumpにはCPU timeが含まれており、CPU time基準で並べ替えると似たようなスレッド群が浮かび上がった
    • スタックはjava.util.TreeMap.put()を指していた
    • アプリケーションコード上の位置はBusinessLogic.someFunction(BusinessLogic.java:29)だった

最初に疑ったコードと退けられた仮説

  • 問題箇所のコードは、unrelatedObjectsを走査しながら、ループ本体ではrelatedObjectしか使っていなかった
for (SomeOtherType unrelatedObject : unrelatedObjects) {
    treeMap.put(relatedObject.a(), relatedObject.b()); // line 29
}
  • このコードは次のように1回のputへ減らせた
treeMap.put(relatedObject.a(), relatedObject.b());
  • unrelatedObjectsは関数の後半で使われているため、引数自体は削除できなかった
  • リファクタリングの過程でunrelatedObjectの利用が消えた可能性があった
  • 単体テストでtreeMapunrelatedObjectsをそれぞれ1,000,000エントリまで増やしても問題は再現できなかった
    • unrelatedObjectsのサイズをNtreeMapのサイズをMとすると計算量はO(N lg(M))
    • 1分級の実行時間を見るには1億〜10億エントリ規模が必要だと判断した
    • 実際のアプリケーションで両データ構造が1,000件を超えないという想定とも一致しなかった

保護されていないTreeMapが生んだ無限ループ

  • TreeMapフィールドの定義は次のとおりだった
private final Map<K,V> treeMap = new TreeMap<>();
  • 複数スレッドがこのTreeMapにアクセスしていたが、同期や保護機構はなかった
  • JavaのTreeMapred-black treeで実装されており、同時変更で内部ノードのリンクが壊れるとcycleが生じうる
  • 探索時や、まだ存在しない値を挿入する過程でcycleをたどり、無限ループに陥ることがある

単純な再現実験

  • 共有TreeMapを複数スレッドがランダム更新する実験を書いた
for (int i = 0; i < numThreads; i++) {
    threads.add(new Thread(() -> {
        Random random = new Random();
        for(int j = 0; j < numUpdates; j++) {
            try {
                treeMap.put(random.nextInt(1000), random.nextInt(1000));
            } catch (NullPointerException e) {
                // let it keep going so we can reproduce the issue.
            }
        }
    }));
}
  • プロジェクト全体はSimpleRepro.javaにある
  • 最初はtry/catchが本質に見えた
    • try/catchがないと、一部スレッドがNullPointerExceptionで死に、プログラムは停止した
    • try/catchを入れた後、数回実行すると500%のCPU使用率を確認できた
  • race conditionはデータ破損やdeadlockだけでなく、データ構造を無限ループ可能な形に壊し、性能障害にもつながりうる

TreeMap内部のcycle確認

  • reflectionでTreeMaprootleftrightkeycolorフィールドにアクセスし、ノードと色を出力する実験を書いた
  • 走査中に、すでに訪問したTreeMap.Entryへ再び到達したらcycleと判断した
private void print(
    Object treeMapEntry, String tabs, IdentityHashMap<Object, Object> visited
) throws Exception {
    if (treeMapEntry != null && !visited.containsKey(treeMapEntry)) {
        visited.put(treeMapEntry, treeMapEntry);
        print(treeMapEntryLeft.get(treeMapEntry), tabs + " ", visited);
        System.out.println(tabs + treeMapEntryKey.get(treeMapEntry) + ":"
            + (treeMapEntryColor.getBoolean(treeMapEntry) ? "BLACK" : "RED"));
        print(treeMapEntryRight.get(treeMapEntry), tabs + " ", visited);
    } else if (treeMapEntry != null && visited.containsKey(treeMapEntry)) {
        System.out.println(tabs + treeMapEntryKey.get(treeMapEntry) + ":"
            + (treeMapEntryColor.getBoolean(treeMapEntry) ? "BLACK" : "RED")
            + " CYCLE"
        );
    }
}
  • Javaモジュールのアクセス制限を開くため、実行時には次のJVM引数が必要だった
--add-opens java.base/java.util=ALL-UNNAMED

既存の関連事例との違い

ExecutorServiceでのより現実的な再現

  • 単にNPEを無視するコードは非現実的に見えるかもしれないが、ExecutorServiceでは例外が表に出にくいことがある
final ExecutorService pool = Executors.newFixedThreadPool(numThreads);
final TreeMap<Integer,Integer> treeMap = new TreeMap<>();
Random random = new Random();

for (int i = 0; i < numThreads*numUpdatesPerThread; i++) {
    pool.submit(() -> {
        treeMap.put(random.nextInt(10000), random.nextInt(10000));
    });
}
  • 全コードはExecutorUncaughtRepro.javaにある
  • 実行するとプログラムは停止し、thread dumpにはTreeMap.put()で引っかかったスレッドが現れる
  • 標準出力には何も出なかった
    • スレッドプールがNPEを飲み込み、問題のシグナルが表に出なかったためだ
    • 実際の状況もこれと同じだった
  • スレッドプールを自前で管理するなら、次の対応が必要になる
    • thread factory経由でuncaught exception handlerを登録する
    • 返されたFutureを処理し、future.get()ExecutionExceptionに包まれたNPEを確認する

gRPCサービスでも同じ問題

  • gRPCサービスのようなスレッドプールベースのサービスでも、保護されていないTreeMapは同じ問題を引き起こす
@Override
public void addReceipt(
    ReceiptProcessorServiceOuterClass.AddReceiptRequest req,
    StreamObserver<ReceiptProcessorServiceOuterClass.AddReceiptResponse> responseObserver
) {
    int timestamp = req.getTimestamp();
    int totalPrice = req.getTotalPrice();
    receipts.put(timestamp, totalPrice);
    ReceiptProcessorServiceOuterClass.AddReceiptResponse response =
        ReceiptProcessorServiceOuterClass.AddReceiptResponse.newBuilder().build();
    responseObserver.onNext(response);
    responseObserver.onCompleted();
}
  • 全コードはGrpcRepro.javaにある
  • protobuf定義はReceiptProcessorService.protoにある
  • thread dumpには、grpc-default-executor-*スレッドがTreeMap.put()でrunnable状態のまま止まっている様子が出ていた

原因に関する推測と言語別の実験

  • 考えられる原因として、2本のスレッドが独立にツリーを逆方向へ回転させたり、重なった回転の書き込みがinterleavingしてcycleを作る状況を疑った
  • ただし、どのスレッドinterleavingがcycleを作るかを示す証明はない
  • 当初はNPEが必要だと疑っていたが、その後の実験でこの仮説は崩れた
  • 複数言語で同じ問題の再現を試みた
    • Javaは本件の元になった言語なので再現できた
    • C++のstd::mapはred-black treeを使っており、まれにsegfaultではなくスレッド停止と高CPU使用率を示した
    • Goでも予想に反して再現でき、コードはGo実験にある
    • RubyはNPEを捕捉できる言語であるにもかかわらず再現できず、GILが問題を生むinterleavingを防いだ可能性がある
  • C++実験では、予想に反してtry/catchやnull pointer例外なしでも無限ループが再現した
    • ときにはsegmentation faultで終了した
    • ごくまれに10分以上進行が止まり、topではSimpleRepro170.8% CPUを使用していた
    • C++でnull pointer参照はsegfaultなので、nullを経由しないinterleavingが存在するはずだ
  • この結果を見てから、Java実験でもNPEを捕まえずに12回再実行し、NPE catchなしでもTreeMap.put()の無限ループを再現した

簡単な修正とデータ構造レベルの防御

  • 最も簡単な修正は、共有TreeMapを保護することだ
    • Collections.synchronizedMapでラップする
    • ConcurrentHashMapに置き換え、ソートが必要なときだけ実行する
  • より議論の余地がある防御策として、red-black tree走査中に訪問済みノードを追跡する方法がある
    • すでに訪れたノードに再び到達したらConcurrentModificationExceptionを投げる
    • データ破損そのものは防げない
    • ただし、無限ループによる100% CPU使用は防げる
    • 追加メモリはツリー高に制限され、O(lg(n))で済む
    • red-black treeの高さはO(lg(n))が保証される
    • 標準ライブラリがこの方式を採用する可能性は低いと見ている
  • 修正例では、getEntryputIdentityHashMapを使って訪問ノードを記録する
IdentityHashMap<Entry<?,?>, Boolean> visited = new IdentityHashMap<>();

while (p != null) {
    visited.put(p, true);
    int cmp = k.compareTo(p.key);
    if (cmp < 0)
        p = p.left;
    else if (cmp > 0)
        p = p.right;
    else
        return p;

    if (visited.containsKey(p)) {
        throw new ConcurrentModificationException("TreeMap corrupted. Loop detected");
    }
}

防御層をいくつも重ねる

  • ミスは起こるため、防御が1層だけでは不十分だ

  • この事例では複数のミスが重なったが、一部の監視が残っていたため問題を発見できた

  • NPEアラート

    • NPE発生そのものに対するアラートはなかった
    • エラー率アラートはあったが、このNPEはAPI handler worker threadごとに1回しか起きず、エラー率の閾値を超えられなかった
    • executorの処理方式のため、NPEログも残らなかった
  • CPU使用率異常アラート

    • CPU使用率を監視し、単純なしきい値ベースのアラートを使っていた
    • CPU使用率がしきい値を超えたことで異常動作と判断され、アラートが発報され、この経路で問題を発見した
  • Executorの例外処理

    • executorへ作業を投入するなら、uncaught exception handlerは必ず設定すべきだ
    thread.setUncaughtExceptionHandler(
        (dyingThread, throwable) -> {
            logger.error("uncaught exception!", throwable);
        }
    );
    
  • コードレビューと静的解析

    • コードレビューでスレッドとTreeMapの組み合わせを見つけたり、ソート不要ならTreeMapを使うべきではないと提案できたかもしれないが、この事例ではそうならなかった
    • SpotBugs、JLint、Chordのような静的解析ツールは、ビルド時点でこの種の並行性問題を見つけられる可能性がある
    • 静的解析に関する資料としてHow Good is Static Analysis at Finding Concurrency Bugs?が挙げられている
  • テスト

    • 当該コード経路にマルチスレッドテストがなかった
    • 同時アクセスがありうるコードなら、テストもマルチスレッド状況を扱うべきだ

結論

  • 保護されていない同時変更は、データ破損だけでなく無限ループや高CPU使用率として現れることがある
  • TreeMapのように内部ポインタ構造を持つデータ構造を複数スレッドが同時に変更すると、red-black tree構造がcycleを作ることがある
  • 同期されたデータ構造の選択、例外処理、アラート、静的解析、マルチスレッドテストを組み合わせれば、問題をより早く発見したり予防したりできる

1件のコメント

 
GN⁺ 2025-03-01
Hacker Newsの意見
  • Javaの中核コレクションが設計上 スレッドセーフではない ことはよく知られているので、これは発見されているべきだった
    OPは残りのコードも見渡して、複数スレッドがコレクションを同時に操作する可能性がある箇所が他にもないか確認すべき
    Collections.synchronizedMapでラップするかConcurrentHashMapに置き換えれば、個々のマップ操作はスレッドセーフになるが、操作の 連続したまとまり まで安全かどうかは別問題
    TreeMapを所有するオブジェクト自体がスレッドセーフかどうかも疑わしく、訪問ノードを追跡するような修正は、コレクションが依然として安全でないまま、より微妙な形で失敗させるだけ
    要点はTreeMapの副作用ではなくコレクションの契約を破ったことであり、HashMapに変えても依然として誤ったコード
    複数スレッドが扱うコードでは、可能な限りすべてのオブジェクトを不変にし、不変にできないオブジェクトは小さく独立し、強く制御された領域にだけ制限するのが最も確実だった

    • この点は本当に重要: スレッドセーフな動的配列があるとしても、size()で長さを確認した後にelement(10)へアクセスする2つの操作の組み合わせは アトミックではない
      2回の呼び出しの間に別スレッドが要素を削除すれば、範囲外アクセスが起こりうる
      解決策は、2つの仕事をまとめて処理するアトミックなメソッド、たとえばelement_or_null()のようなものを使うか、そもそも通常の配列を使って2つの操作全体をミューテックスで保護すること
    • 同期されていない 共有可変状態 でデータ競合が起きる
      3条件がすべて必要なので、解決策も3つある: ロックなどで同期を追加する、チャネルを使う単一所有者モデルのように可変アクセスを共有しない、純粋関数型言語からの知見のようにデータを不変にする
      GoogleもGuavaでこのモデルにかなり投資していたと認識している
      Rustはこの3つのうち何を諦めるか選ばせ、3条件が同時に成立することを静的に防いでくれる
    • 最初に思ったのも「待って、そのデータ構造はスレッドセーフなのか?」で、結局のところ目的に合っていない データ構造の選択 に見える
      ミューテックスで無理やり当てはめると、たいてい別の問題やボトルネックが生じる
      不変オブジェクトを使うなら、構造的共有を活用する不変データ構造を使ってこそ、メモリ使用量の爆発を避けるか抑えられる
      純粋関数型データ構造を使えば問題の片側は消えるが、スレッド同士が互いに作った中間バージョンに依存するなら、また別の厄介な問題が生じ、別のデータ構造が必要になるかもしれない
      すでに使っている可変データ構造を無理やり生かすなら、アクセスの試みをデータ構造に触れる前に直列化し、アクセスをトランザクションとして束ねて完全なトランザクションだけ実行させることもできるが、そうなるとほとんどデータベースを実装している感じになる
    • 共有メモリ環境で独立実行をしようとする スレッディングモデル 自体が根本的に間違っていると見る
      それを動かすための努力は、よりよいプロセスモデルに向けられるべきだったと思う
  • 原文でコードを単純化した例は正確ではない
    元のコードではunrelatedObjectsが空でないときだけtreeMap.putを実行しており、この違いがバグかもしれないし、そうでないかもしれない
    また、abが毎回同じ値を返すのか、treeMapが本当にマップとしてだけ動作するのかも確認する必要がある
    たとえば更新をログに残すマップなら、一度だけログを残すように変えても問題ないか検討すべき

    • もっともな指摘。空でないことを確認するifに変えるべき
  • ComparatorComparable の実装が一貫した全順序を提供していなくても、無限ループが起こりうる: https://stackoverflow.com/questions/62994606/concurrentskips...
    これは並行性とは無関係
    発生するかどうかは処理される具体的なデータと処理順序に左右されるので、長い間正常に見えていても本番環境で爆発することがある

    • 実際に直接見たことがあるのか気になる。良いブログ記事のネタになりそう
      全順序を持たないバグった比較子には、個人的にはまだ遭遇したことがない
  • 競合状態はデータ破損やデッドロックを生むだけで、性能問題まで引き起こし得るとは考えていなかった
    ただ、データを壊して無限ループを作れるなら、そうなっても不思議ではない
    だから私は、プロジェクトのエラー、異常動作、警告は原則として直すべきだとよく考える。見た目には無関係な問題を引き起こし得るからだ
    ただし、何をやるかを決める側でこうした原則が受け入れられることはまれだ

    • 良い経験則ではあるが、実用性は必要だ
      プロジェクトによっては、すべてのエラー、異常動作、警告をなくすコストが、たまに起きる一見無関係な問題を受け入れるコストよりはるかに大きいことがある
      特定のエラーが将来の問題に関与する可能性や、先回りして直すコストのほうが安いかどうかはほとんど予測不可能なので、結局は科学というより技術に近い
      「何も直さない」はひどいし、「全部直す」はたいてい非現実的なので、意思決定の枠組みや経験に基づく勘、そして利害関係者の信頼が必要になる
    • 職場ではすべての警告をエラーとして扱い、自動実行されるすべての CI パイプラインが通らない限りプルリクエストをマージできない
      規律は必要だが、いったんその状態にしてしまえば維持はずっと楽になる
    • 例外を捕まえて無視しないことも重要だ
      プログラムの実行を続けても安全だと具体的に分かっているのでなければ、「捕まえてログだけ残す」のも悪い考えだ
      単に例外を伝播させて 500 を返したり、エラーダイアログを表示したりするなど、有用な処理ができる場所まで上げるほうがよい
    • 結局は戦う場所を選ばなければならない
      保守している Rails プロジェクトで、ログが “unsupported parameters” だらけになる問題があったが、コントローラを細かく確認して許可しても出続けた
      おそらく無害だが、ログに大量のノイズが入る
      何人かが解決を試みたが、いつももっと優先度の高いことがあり、機能への影響がないことに多くの時間を使う正当化もしづらかった
      痔のように厄介な問題だ。手術して数週間の大きな苦痛を受けるか、ただ耐えて過ごすかの選択で、たいていは良性だが悪化して大きな問題になる可能性もある
      こういう現象はデジタル痔と呼んでもよさそうだ
    • 破損がなくても、競合状態は同じ作業を何度も行わせ、結果は 1 つだけ残して大きな性能問題を引き起こし得る
      警告が無関係だと判断したなら、少なくともコメントで説明し、可能なら警告をできるだけ狭い範囲で無効にする pragma を付けるほうがよい
      異常動作はなくすほうを好む。「なぜ動くのかは分からないが」と書かれたコードが後になって動かなくなり、事前に整理していれば慎重に直す時間があったはずなのに、急いで書き直す羽目になったことがある
  • 「ssh でかろうじて接続できた」という部分を見て、大学院時代の状況を思い出した
    うちの研究室は並列・分散処理関連の小規模グループ 8 人ほどで、Sun UltraSparc 170 と思われるマシンを共有しており、ハードディスクは 1GB、RAM は 128 または 256MB 程度だった
    Sun の機器はほとんど再起動しないという点も考慮する必要がある
    新しい学生が大きなテキストファイルを行番号を基準に 32 個か 64 個の区間に分け、別ファイルを作らずに perl のコピー N 個を並列実行し、それぞれ自分の行区間を処理させたようだった
    当時としては大きな RAM を perl インタプリタ N 個が食い、スワップが始まると同じファイルの別区間へ狂ったようにシークしながら、各プロセスがさらに数行ずつ読もうとしていた
    しかも J 番目のプロセスは自分の区間に到達するために、ファイルの J/N を読まなければならなかった可能性が高い
    コンソールではログインプロンプトすら出ないほどだったが、幸い既にログイン済みのセッションがあり、su が 20~30 分後にパスワードプロンプトを表示した
    さらに 5~10 分後に root セッションを得て、top で原因を確認し、ユーザーに連絡を試みて問題のプロセスを kill すると、システムは正常に戻った
    アイデア自体は正しかったが、システムの限界をまったく理解していなかった例で、ハードディスクと少ない RAM のため I/O ボトルネック が極端にひどく、単純に線形処理したほうがずっと良かったはずだ

  • Java であれ他の言語であれ、スレッドセーフでないオブジェクトに対して並行操作を行うと、この世で最も興味深いバグが出てくる

    • だからスレッドセーフでないオブジェクトを複数スレッドで使うときは、原子的なアクセスを自分で管理するか、スレッドセーフな版を使うべきだ
      マルチスレッドのバグはデバッグが最悪だ
      このケースは設計時点で見つけるのがとても簡単で、マルチスレッド環境で通常のコンテナを使おうとした時点で警報が鳴るべきだった
    • 並行性バグはかなり経験したが、Java の可変フィールドから volatile 修飾子を外したときに生じる不整合を意図的に再現したことはない
      当時使っていた JVM があまりにもよくできていたのかもしれない
    • 同期されていないものを共有しても、その結果を受け入れられると確信していたときでさえ、実際の結果はいつも予想外だった
    • やや過激な考えだが、この理由で C/C++ の競合デバッグのほうを好む
      言語仕様は競合発生時に事実上ほとんど意味のない狂った意味論を定めてはいるが、実際にはメモリ破損や派手な問題として表に出ることが多い
      競合の大半が違法だからこそ、意味を示すためにソースコードを変えなくてもスレッドチェッカーのようなツールを作れる
      一方 Java には未定義動作はないが、2 つのフィールドが微妙にずれるようなことが起きやすく、こういうものは追跡がはるかに難しい
    • Java Collections の一部、あるいは大半の操作は、こうした問題を警告するために整合性検査を行う
      たとえばマップが ConcurrentModificationException を投げるようなものだ
  • 本番運用中の C# でも同じ現象を見たことがある
    症状は同じで、プロセスダンプを見ると壊れたディクショナリがあった
    すべて ConcurrentDictionary を使っていると思っていたが、ライブラリ由来の 1 つが問題だった
    当時は .NET Framework を使っており、記憶では .NET Core には並行変更を検知するコードがある
    実装はよく分からないが、バージョンカウンタがあるだけでも可能だろう
    NPE が核心の材料であるかのようにこだわっている点は奇妙だ。本来の発生パターンにはそれがなさそうだし、例外がないという理由だけで C にこうしたバグがないと考える理由もない
    核心はクラス不変条件だ。一般に、変更処理の実行中は不変条件が成り立たず、最後になってようやく回復する
    不変条件が回復する前に別の変更処理を実行すると、データ構造が壊れる。有効な状態から始まらなければ、終わったときに有効な状態である可能性は低い

    • 結局は誤ったロジックのせいだった
      捕捉されない NPE では再現しないという不運に遭い、捕捉されない NPE が必要条件だと誤って結論づけていた
  • 同期化が不十分な java.util.HashMap でも同じようなことを見たことがある
    2009年ごろの話だが、知る限りでは今でも起こりうる
    記憶では HashMap は衝突解決にチェイニングを使っていて、おそらくチェイン内に循環ができていたのだと思う
    ただ、検証のために深掘りするよりは、問題のコードを丸ごと取り除くことに集中した
    並行性の知識は面接でよく問われるが、データ競合を些細な問題程度に考えていると良い印象は与えにくく、この事例がその理由だ

    • HashMap でもこんなことが起こりうるとは知らなかった
      衝突処理に使う連結リストに関係しているのか?
  • 循環を見つけるために増加カウンタを置き、ツリーの深さやコレクションのサイズより大きくなったら例外を投げる方法はどうだろうと思う
    著者が提案したハッシュ集合方式と違って、メモリや CPU のオーバーヘッドがほとんどなく、受け入れられる可能性もより高そうだ
    ただ、C# を10年以上使ってきて、並行状況でデータ構造への同時アクセスを考慮しなかったことはなかった

    • 悪いアイデアではないが、データ競合がある状況で有用な 事前条件 を追加するのは一般にかなり難しい
      ツリーが壊れるパターンはこれ以外にも多い
    • 良いアイデアだ。二分探索やツリー構造で以前やったことがある
      可能なら無限ループを避けたいし、避けられないケースはかなりまれだ
      修正ではないが、良い緩和策ではある
      無限ループは最悪級のバグのひとつだ。デバッガでは見つけやすいが、OP の「sshもかろうじて使える」状況のように、結果が非常に不親切になることがある
      とりわけライブラリコードの無限ループはさらに不快だ
    • そのほうがずっと良い。定数メモリ しか使わない
      ノード数はツリーの高さ以下だという保証がある
  • スレッド内の 例外 は本当に致命的だ
    C++、select()、例外を投げるスレッドが主役のひどいバグ追跡話がある: https://news.ycombinator.com/item?id=42532979

    • その話を読んだ記憶はあるが、その分野の知識が足りなくて理解できなかった
      もう一度読んでみようと思う